yams/tests/unit/metadata/metadata_repository_catch2_test.cpp
2026-08-03 15:40:16 -04:00

5060 lines
206 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2025 YAMS Contributors
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <future>
#include <iterator>
#include <memory>
#include <optional>
#include <sstream>
#include <thread>
#include <variant>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <spdlog/sinks/ostream_sink.h>
#include <boost/asio/io_context.hpp>
#include <yams/common/utf8_utils.h>
#include <yams/daemon/components/MetadataWriteFacade.h>
#include <yams/daemon/components/TuneAdvisor.h>
#include <yams/daemon/components/WriteCoordinator.h>
#include <yams/metadata/connection_pool.h>
#include "../../common/metadata_test_db.h"
#include <yams/metadata/content_index_writer.h>
#include <yams/metadata/database.h>
#include <yams/metadata/metadata_insert_writer.h>
#include <yams/metadata/metadata_repository.h>
#include <yams/metadata/migration.h>
#include <yams/metadata/path_utils.h>
#include <yams/storage/corpus_stats.h>
using namespace yams;
using namespace yams::metadata;
using namespace yams::search;
// Catch2's expression-decomposition macros intentionally overload operator<=,
// which triggers false positives from chained-comparison linters on ordinary
// CHECK/REQUIRE assertions in this file.
// NOLINTBEGIN(bugprone-chained-comparison)
namespace {
std::filesystem::path tempDbPath(const char* prefix) {
static const bool kSuppressInfoLogs = [] {
spdlog::set_level(spdlog::level::warn);
return true;
}();
(void)kSuppressInfoLogs;
return yams::test::migrated_metadata_db_template().clone(prefix);
}
class SpdlogCaptureGuard {
public:
explicit SpdlogCaptureGuard(spdlog::level::level_enum level)
: previousLogger_(spdlog::default_logger()), previousLevel_(spdlog::get_level()) {
auto sink = std::make_shared<spdlog::sinks::ostream_sink_mt>(stream_);
logger_ = std::make_shared<spdlog::logger>("metadata_repository_capture", sink);
logger_->set_level(level);
logger_->set_pattern("[%l] %v");
spdlog::set_default_logger(logger_);
spdlog::set_level(level);
}
~SpdlogCaptureGuard() {
if (previousLogger_) {
spdlog::set_default_logger(previousLogger_);
}
spdlog::set_level(previousLevel_);
}
std::string str() const { return stream_.str(); }
private:
std::ostringstream stream_;
std::shared_ptr<spdlog::logger> previousLogger_;
std::shared_ptr<spdlog::logger> logger_;
spdlog::level::level_enum previousLevel_;
};
DocumentInfo makeDocumentWithPath(const std::string& path, const std::string& hash,
const std::string& mime = "text/plain") {
DocumentInfo info;
info.filePath = path;
info.fileName = std::filesystem::path(path).filename().string();
info.fileExtension = std::filesystem::path(path).extension().string();
info.fileSize = 1234;
info.sha256Hash = hash;
info.mimeType = mime;
info.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
info.modifiedTime = info.createdTime;
info.indexedTime = info.createdTime;
populatePathDerivedFields(info);
info.contentExtracted = true;
info.extractionStatus = ExtractionStatus::Success;
return info;
}
BatchContentEntry makeBatchContentEntry(int64_t documentId, const std::string& title,
const std::string& contentText,
const std::string& mimeType = "text/plain",
const std::string& extractionMethod = "test",
const std::string& language = "en") {
BatchContentEntry entry;
entry.documentId = documentId;
entry.title = title;
entry.contentText = contentText;
entry.mimeType = mimeType;
entry.extractionMethod = extractionMethod;
entry.language = language;
return entry;
}
std::unique_ptr<yams::daemon::WriteBatch> makeSymSpellBatch(const std::string& source,
const std::string& indexedTerm) {
auto batch = std::make_unique<yams::daemon::WriteBatch>();
batch->source = source;
batch->ops.emplace_back(yams::daemon::AddSymSpellTermsOp{{indexedTerm}});
return batch;
}
BatchDocumentInsert makeIndexedBatchInsert(int index) {
BatchDocumentInsert item;
item.info = makeDocumentWithPath("repo/writer/doc-" + std::to_string(index) + ".txt",
"writer-hash-" + std::to_string(index));
item.tags.emplace_back("idx", MetadataValue(std::to_string(index)));
return item;
}
TEST_CASE("populatePathDerivedFields fills DocumentInfo path index fields", "[metadata][path]") {
DocumentInfo info;
info.filePath = "/tmp/../tmp/yams/path-note.md";
const auto expected = computePathDerivedValues(info.filePath);
populatePathDerivedFields(info);
CHECK((info.filePath == expected.normalizedPath));
CHECK((info.pathPrefix == expected.pathPrefix));
CHECK((info.reversePath == expected.reversePath));
CHECK((info.pathHash == expected.pathHash));
CHECK((info.parentHash == expected.parentHash));
CHECK((info.pathDepth == expected.pathDepth));
}
struct MetadataRepositoryFixture {
MetadataRepositoryFixture() {
dbPath_ = tempDbPath("metadata_repo_catch2_test_");
ConnectionPoolConfig config;
config.minConnections = 1;
config.maxConnections = 2;
pool_ = std::make_unique<ConnectionPool>(dbPath_.string(), config);
auto initResult = pool_->initialize();
REQUIRE((initResult.has_value()));
repository_ = std::make_unique<MetadataRepository>(
*pool_, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
}
~MetadataRepositoryFixture() {
repository_.reset();
pool_->shutdown();
pool_.reset();
yams::test::remove_sqlite_artifacts(dbPath_);
}
std::filesystem::path dbPath_;
std::unique_ptr<ConnectionPool> pool_;
std::unique_ptr<MetadataRepository> repository_;
};
struct EmbeddingStatusRow {
bool hasEmbedding = false;
std::optional<std::string> modelId;
int64_t chunkCount = 0;
};
Result<std::optional<EmbeddingStatusRow>> getEmbeddingStatusRow(ConnectionPool& pool,
const std::string& hash) {
auto connResult = pool.acquire();
if (!connResult) {
return connResult.error();
}
auto& db = **connResult.value();
auto stmtResult = db.prepare(R"(
SELECT COALESCE(des.has_embedding, 0), des.model_id, COALESCE(des.chunk_count, 0)
FROM documents d
LEFT JOIN document_embeddings_status des ON d.id = des.document_id
WHERE d.sha256_hash = ?
)");
if (!stmtResult) {
return stmtResult.error();
}
auto& stmt = stmtResult.value();
if (auto bindResult = stmt.bind(1, hash); !bindResult) {
return bindResult.error();
}
auto stepResult = stmt.step();
if (!stepResult) {
return stepResult.error();
}
if (!stepResult.value()) {
return std::optional<EmbeddingStatusRow>{};
}
EmbeddingStatusRow row;
row.hasEmbedding = stmt.getInt(0) != 0;
if (!stmt.isNull(1)) {
row.modelId = stmt.getString(1);
}
row.chunkCount = stmt.getInt64(2);
return std::optional<EmbeddingStatusRow>{row};
}
Result<void> setEmbeddingChunkCount(ConnectionPool& pool, const std::string& hash,
int64_t chunkCount) {
auto connResult = pool.acquire();
if (!connResult) {
return connResult.error();
}
auto& db = **connResult.value();
auto stmtResult = db.prepare(R"(
UPDATE document_embeddings_status
SET chunk_count = ?
WHERE document_id = (SELECT id FROM documents WHERE sha256_hash = ?)
)");
if (!stmtResult) {
return stmtResult.error();
}
auto& stmt = stmtResult.value();
if (auto bindCount = stmt.bind(1, chunkCount); !bindCount) {
return bindCount.error();
}
if (auto bindHash = stmt.bind(2, hash); !bindHash) {
return bindHash.error();
}
return stmt.execute();
}
Result<void> overwritePathTreeCentroid(ConnectionPool& pool, std::string_view fullPath,
std::span<const std::byte> blob, int64_t centroidWeight) {
auto connResult = pool.acquire();
if (!connResult) {
return connResult.error();
}
auto& db = **connResult.value();
auto stmtResult = db.prepare(R"(
UPDATE path_tree_nodes
SET centroid = ?, centroid_weight = ?
WHERE full_path = ?
)");
if (!stmtResult) {
return stmtResult.error();
}
auto& stmt = stmtResult.value();
if (auto bindBlob = stmt.bind(1, blob); !bindBlob) {
return bindBlob.error();
}
if (auto bindWeight = stmt.bind(2, centroidWeight); !bindWeight) {
return bindWeight.error();
}
if (auto bindPath = stmt.bind(3, fullPath); !bindPath) {
return bindPath.error();
}
return stmt.execute();
}
} // namespace
TEST_CASE("MetadataRepository: path-derived fields support exact path lookup",
"[unit][metadata][repository][path]") {
MetadataRepositoryFixture fix;
auto docInfo = makeDocumentWithPath("/tmp/../tmp/yams/path-index.md", "path-index-hash");
REQUIRE((!docInfo.pathHash.empty()));
REQUIRE((!docInfo.reversePath.empty()));
REQUIRE((docInfo.pathDepth > 0));
auto insert = fix.repository_->insertDocument(docInfo);
REQUIRE((insert.has_value()));
auto found = fix.repository_->findDocumentByExactPath(docInfo.filePath);
REQUIRE((found.has_value()));
REQUIRE((found.value().has_value()));
CHECK((found.value()->pathHash == docInfo.pathHash));
CHECK((found.value()->pathPrefix == docInfo.pathPrefix));
CHECK((found.value()->parentHash == docInfo.parentHash));
CHECK((found.value()->pathDepth == docInfo.pathDepth));
}
TEST_CASE("MetadataRepository: insert and get document", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "hash123";
docInfo.fileName = "test.txt";
docInfo.fileSize = 1024;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto result = fix.repository_->insertDocument(docInfo);
REQUIRE((result.has_value()));
auto docId = result.value();
CHECK((docId > 0));
auto getResult = fix.repository_->getDocument(docId);
REQUIRE((getResult.has_value()));
REQUIRE((getResult.value().has_value()));
auto retrievedDoc = getResult.value().value();
CHECK((retrievedDoc.id == docId));
CHECK((retrievedDoc.sha256Hash == docInfo.sha256Hash));
CHECK((retrievedDoc.fileName == docInfo.fileName));
CHECK((retrievedDoc.fileSize == docInfo.fileSize));
CHECK((retrievedDoc.mimeType == docInfo.mimeType));
}
TEST_CASE("MetadataRepository: dual-pool read/write routing behavior",
"[unit][metadata][repository][dual-pool]") {
auto dbPath = tempDbPath("metadata_repo_dual_pool_");
ConnectionPoolConfig writeCfg;
writeCfg.minConnections = 1;
writeCfg.maxConnections = 2;
ConnectionPoolConfig readCfg;
readCfg.minConnections = 1;
readCfg.maxConnections = 2;
auto writePool = std::make_unique<ConnectionPool>(dbPath.string(), writeCfg);
auto readPool = std::make_unique<ConnectionPool>(dbPath.string(), readCfg);
REQUIRE((writePool->initialize().has_value()));
REQUIRE((readPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*writePool, readPool.get(), MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto doc = makeDocumentWithPath("/tmp/dual-pool.txt", "dual-pool-hash");
auto insertResult = repository->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
auto readOk = repository->getDocumentByHash("dual-pool-hash");
REQUIRE((readOk.has_value()));
REQUIRE((readOk.value().has_value()));
readPool->shutdown();
SpdlogCaptureGuard logCapture(spdlog::level::err);
auto readAfterShutdown = repository->getDocumentByHash("dual-pool-hash");
REQUIRE_FALSE(readAfterShutdown.has_value());
CHECK(((readAfterShutdown.error().code == ErrorCode::OperationCancelled ||
readAfterShutdown.error().code == ErrorCode::InvalidState)));
CHECK((readAfterShutdown.error().message.find("shut down") != std::string::npos ||
readAfterShutdown.error().message.find("shutdown") != std::string::npos));
CHECK((logCapture.str().find("MetadataRepository::executeQueryOnPool") == std::string::npos));
MetadataValue value;
value.value = "still-writeable";
value.type = MetadataValueType::String;
auto writeAfterReadPoolDown = repository->setMetadata(docId, "dual_pool_test", value);
REQUIRE((writeAfterReadPoolDown.has_value()));
repository.reset();
writePool->shutdown();
writePool.reset();
readPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: dual-pool session count read route isolation",
"[unit][metadata][repository][dual-pool]") {
auto dbPath = tempDbPath("metadata_repo_dual_pool_session_count_");
ConnectionPoolConfig writeCfg;
writeCfg.minConnections = 1;
writeCfg.maxConnections = 2;
ConnectionPoolConfig readCfg;
readCfg.minConnections = 1;
readCfg.maxConnections = 2;
auto writePool = std::make_unique<ConnectionPool>(dbPath.string(), writeCfg);
auto readPool = std::make_unique<ConnectionPool>(dbPath.string(), readCfg);
REQUIRE((writePool->initialize().has_value()));
REQUIRE((readPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*writePool, readPool.get(), MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto doc = makeDocumentWithPath("/tmp/dual-pool-session.txt", "dual-pool-session-hash");
auto insertResult = repository->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
MetadataValue sessionValue;
sessionValue.value = "sess-123";
sessionValue.type = MetadataValueType::String;
REQUIRE((repository->setMetadata(docId, "session_id", sessionValue).has_value()));
auto countOk = repository->countDocumentsBySessionId("sess-123");
REQUIRE((countOk.has_value()));
REQUIRE((countOk.value() == 1));
readPool->shutdown();
auto countAfterShutdown = repository->countDocumentsBySessionId("sess-123");
REQUIRE_FALSE(countAfterShutdown.has_value());
MetadataValue writeValue;
writeValue.value = "still-writeable-after-read-pool-down";
writeValue.type = MetadataValueType::String;
REQUIRE((repository->setMetadata(docId, "dual_pool_write_check", writeValue).has_value()));
repository.reset();
writePool->shutdown();
writePool.reset();
readPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: snapshot metadata helpers round-trip",
"[unit][metadata][repository][snapshot-metadata]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("repo/releases/a.txt", "snapshot-meta-a");
auto docB = makeDocumentWithPath("repo/releases/b.txt", "snapshot-meta-b");
auto docC = makeDocumentWithPath("repo/archive/c.txt", "snapshot-meta-c");
auto aId = fix.repository_->insertDocument(docA);
auto bId = fix.repository_->insertDocument(docB);
auto cId = fix.repository_->insertDocument(docC);
REQUIRE((aId.has_value()));
REQUIRE((bId.has_value()));
REQUIRE((cId.has_value()));
auto setStringMeta = [&](int64_t docId, const std::string& key, const std::string& value) {
MetadataValue mv;
mv.type = MetadataValueType::String;
mv.value = value;
REQUIRE((fix.repository_->setMetadata(docId, key, mv).has_value()));
};
setStringMeta(aId.value(), "snapshot_id", "snap-1");
setStringMeta(aId.value(), "snapshot_label", "release-a");
setStringMeta(aId.value(), "git_commit", "abc123");
setStringMeta(bId.value(), "snapshot_id", "snap-1");
setStringMeta(bId.value(), "snapshot_label", "release-a");
setStringMeta(bId.value(), "git_commit", "abc123");
setStringMeta(cId.value(), "snapshot_id", "snap-2");
setStringMeta(cId.value(), "snapshot_label", "release-b");
setStringMeta(cId.value(), "git_commit", "def456");
auto bySnapshot = fix.repository_->findDocumentsBySnapshot("snap-1");
REQUIRE((bySnapshot.has_value()));
REQUIRE((bySnapshot.value().size() == 2));
std::vector<std::string> snapshotPaths;
for (const auto& doc : bySnapshot.value()) {
snapshotPaths.push_back(doc.filePath);
}
std::sort(snapshotPaths.begin(), snapshotPaths.end());
auto expectedSnapshotPaths = std::vector<std::string>{docA.filePath, docB.filePath};
std::sort(expectedSnapshotPaths.begin(), expectedSnapshotPaths.end());
CHECK((snapshotPaths == expectedSnapshotPaths));
auto byLabel = fix.repository_->findDocumentsBySnapshotLabel("release-a");
REQUIRE((byLabel.has_value()));
REQUIRE((byLabel.value().size() == 2));
auto snapshots = fix.repository_->getSnapshots();
REQUIRE((snapshots.has_value()));
CHECK((snapshots.value() == std::vector<std::string>{"snap-1", "snap-2"}));
auto snapshotsCached = fix.repository_->getSnapshots();
REQUIRE((snapshotsCached.has_value()));
CHECK((snapshotsCached.value() == snapshots.value()));
auto labels = fix.repository_->getSnapshotLabels();
REQUIRE((labels.has_value()));
CHECK((labels.value() == std::vector<std::string>{"release-a", "release-b"}));
auto labelsCached = fix.repository_->getSnapshotLabels();
REQUIRE((labelsCached.has_value()));
CHECK((labelsCached.value() == labels.value()));
auto snap1Info = fix.repository_->getSnapshotInfo("snap-1");
REQUIRE((snap1Info.has_value()));
CHECK((snap1Info.value().fileCount == 2));
CHECK((snap1Info.value().label == "release-a"));
CHECK((snap1Info.value().gitCommit == "abc123"));
CHECK((snap1Info.value().createdTime > 0));
auto batchInfo = fix.repository_->batchGetSnapshotInfo({"snap-1", "snap-2"});
REQUIRE((batchInfo.has_value()));
REQUIRE((batchInfo.value().size() == 2));
CHECK((batchInfo.value().at("snap-1").fileCount == 2));
CHECK((batchInfo.value().at("snap-1").label == "release-a"));
CHECK((batchInfo.value().at("snap-2").fileCount == 1));
CHECK((batchInfo.value().at("snap-2").gitCommit == "def456"));
}
TEST_CASE("MetadataRepository: session and tag helpers round-trip",
"[unit][metadata][repository][session-tags]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("repo/sessions/a.txt", "session-tag-a");
auto docB = makeDocumentWithPath("repo/sessions/b.txt", "session-tag-b");
auto docC = makeDocumentWithPath("repo/archive/c.txt", "session-tag-c");
auto aId = fix.repository_->insertDocument(docA);
auto bId = fix.repository_->insertDocument(docB);
auto cId = fix.repository_->insertDocument(docC);
REQUIRE((aId.has_value()));
REQUIRE((bId.has_value()));
REQUIRE((cId.has_value()));
auto setStringMeta = [&](int64_t docId, const std::string& key, const std::string& value) {
MetadataValue mv;
mv.type = MetadataValueType::String;
mv.value = value;
REQUIRE((fix.repository_->setMetadata(docId, key, mv).has_value()));
};
setStringMeta(aId.value(), "session_id", "sess-a");
setStringMeta(bId.value(), "session_id", "sess-a");
setStringMeta(cId.value(), "session_id", "sess-b");
auto sessionDocs = fix.repository_->findDocumentsBySessionId("sess-a");
REQUIRE((sessionDocs.has_value()));
REQUIRE((sessionDocs.value().size() == 2));
std::vector<std::string> sessionPaths;
for (const auto& doc : sessionDocs.value()) {
sessionPaths.push_back(doc.filePath);
}
std::sort(sessionPaths.begin(), sessionPaths.end());
auto expectedSessionPaths = std::vector<std::string>{docA.filePath, docB.filePath};
std::sort(expectedSessionPaths.begin(), expectedSessionPaths.end());
CHECK((sessionPaths == expectedSessionPaths));
auto sessionCount = fix.repository_->countDocumentsBySessionId("sess-a");
REQUIRE((sessionCount.has_value()));
CHECK((sessionCount.value() == 2));
REQUIRE((fix.repository_->removeSessionIdFromDocuments("sess-a").has_value()));
auto sessionCountAfterRemove = fix.repository_->countDocumentsBySessionId("sess-a");
REQUIRE((sessionCountAfterRemove.has_value()));
CHECK((sessionCountAfterRemove.value() == 0));
auto preservedDoc = fix.repository_->getDocument(aId.value());
REQUIRE((preservedDoc.has_value()));
REQUIRE((preservedDoc.value().has_value()));
setStringMeta(aId.value(), "session_id", "sess-a");
setStringMeta(bId.value(), "session_id", "sess-a");
auto deletedCount = fix.repository_->deleteDocumentsBySessionId("sess-a");
REQUIRE((deletedCount.has_value()));
CHECK((deletedCount.value() == 2));
auto deletedA = fix.repository_->getDocument(aId.value());
auto deletedB = fix.repository_->getDocument(bId.value());
auto survivingC = fix.repository_->getDocument(cId.value());
REQUIRE((deletedA.has_value()));
REQUIRE((deletedB.has_value()));
REQUIRE((survivingC.has_value()));
CHECK_FALSE(deletedA.value().has_value());
CHECK_FALSE(deletedB.value().has_value());
REQUIRE((survivingC.value().has_value()));
setStringMeta(cId.value(), "tag:alpha", "alpha");
setStringMeta(cId.value(), "tag:beta", "beta");
auto docD = makeDocumentWithPath("repo/tags/d.txt", "session-tag-d");
auto dId = fix.repository_->insertDocument(docD);
REQUIRE((dId.has_value()));
setStringMeta(dId.value(), "tag:alpha", "alpha");
setStringMeta(dId.value(), "tag", "legacy");
auto docCTags = fix.repository_->getDocumentTags(cId.value());
REQUIRE((docCTags.has_value()));
CHECK((docCTags.value() == std::vector<std::string>{"alpha", "beta"}));
auto batchTags = fix.repository_->batchGetDocumentTags(
std::vector<int64_t>{cId.value(), dId.value(), 999999});
REQUIRE((batchTags.has_value()));
REQUIRE((batchTags.value().contains(cId.value())));
REQUIRE((batchTags.value().contains(dId.value())));
CHECK((batchTags.value().at(cId.value()) == std::vector<std::string>{"alpha", "beta"}));
CHECK((batchTags.value().at(dId.value()) == std::vector<std::string>{"alpha"}));
CHECK_FALSE(batchTags.value().contains(999999));
auto allTags = fix.repository_->getAllTags();
REQUIRE((allTags.has_value()));
CHECK((allTags.value() == std::vector<std::string>{"alpha", "beta"}));
auto allTagsCached = fix.repository_->getAllTags();
REQUIRE((allTagsCached.has_value()));
CHECK((allTagsCached.value() == allTags.value()));
setStringMeta(dId.value(), "tag:gamma", "gamma");
auto allTagsAfterInsert = fix.repository_->getAllTags();
REQUIRE((allTagsAfterInsert.has_value()));
CHECK((allTagsAfterInsert.value() == std::vector<std::string>{"alpha", "beta", "gamma"}));
auto anyAlpha = fix.repository_->findDocumentsByTags({"alpha"}, false);
REQUIRE((anyAlpha.has_value()));
REQUIRE((anyAlpha.value().size() == 2));
auto matchAll = fix.repository_->findDocumentsByTags({"alpha", "beta"}, true);
REQUIRE((matchAll.has_value()));
REQUIRE((matchAll.value().size() == 1));
CHECK((matchAll.value().front().id == cId.value()));
auto legacyMatch = fix.repository_->findDocumentsByTags({"legacy"}, false);
REQUIRE((legacyMatch.has_value()));
REQUIRE((legacyMatch.value().size() == 1));
CHECK((legacyMatch.value().front().id == dId.value()));
auto emptyMatch = fix.repository_->findDocumentsByTags({}, true);
REQUIRE((emptyMatch.has_value()));
CHECK((emptyMatch.value().empty()));
}
TEST_CASE("MetadataRepository: dual-pool embedding-hash read route isolation",
"[unit][metadata][repository][dual-pool]") {
auto dbPath = tempDbPath("metadata_repo_dual_pool_embedding_hash_");
ConnectionPoolConfig writeCfg;
writeCfg.minConnections = 1;
writeCfg.maxConnections = 2;
ConnectionPoolConfig readCfg;
readCfg.minConnections = 1;
readCfg.maxConnections = 2;
auto writePool = std::make_unique<ConnectionPool>(dbPath.string(), writeCfg);
auto readPool = std::make_unique<ConnectionPool>(dbPath.string(), readCfg);
REQUIRE((writePool->initialize().has_value()));
REQUIRE((readPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*writePool, readPool.get(), MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto doc = makeDocumentWithPath("/tmp/dual-pool-embed-hash.txt", "dual-pool-embed-hash");
auto insertResult = repository->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto readOk = repository->hasDocumentEmbeddingByHash("dual-pool-embed-hash");
REQUIRE((readOk.has_value()));
REQUIRE_FALSE(readOk.value());
readPool->shutdown();
auto readAfterShutdown = repository->hasDocumentEmbeddingByHash("dual-pool-embed-hash");
REQUIRE_FALSE(readAfterShutdown.has_value());
repository.reset();
writePool->shutdown();
writePool.reset();
readPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: reconcile embedding status from vector-backed hashes",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/reconcile_a.txt", "reconcile-hash-a");
auto docB = makeDocumentWithPath("/tmp/reconcile_b.txt", "reconcile-hash-b");
auto docAId = fix.repository_->insertDocument(docA);
auto docBId = fix.repository_->insertDocument(docB);
REQUIRE((docAId.has_value()));
REQUIRE((docBId.has_value()));
REQUIRE(fix.repository_
->updateDocumentEmbeddingStatusByHash("reconcile-hash-a", true, "legacy-embed")
.has_value());
REQUIRE(fix.repository_
->updateDocumentEmbeddingStatusByHash("reconcile-hash-b", true, "legacy-embed")
.has_value());
auto beforeA = fix.repository_->hasDocumentEmbeddingByHash("reconcile-hash-a");
auto beforeB = fix.repository_->hasDocumentEmbeddingByHash("reconcile-hash-b");
REQUIRE((beforeA.has_value()));
REQUIRE((beforeB.has_value()));
CHECK((beforeA.value()));
CHECK((beforeB.value()));
REQUIRE(fix.repository_
->reconcileDocumentEmbeddingStatusByHashes({"reconcile-hash-a", "missing-hash"})
.has_value());
auto afterA = fix.repository_->hasDocumentEmbeddingByHash("reconcile-hash-a");
auto afterB = fix.repository_->hasDocumentEmbeddingByHash("reconcile-hash-b");
REQUIRE((afterA.has_value()));
REQUIRE((afterB.has_value()));
CHECK((afterA.value()));
CHECK_FALSE(afterB.value());
auto stats = fix.repository_->getCorpusStats();
REQUIRE((stats.has_value()));
CHECK((stats.value().embeddingCount == 1));
}
TEST_CASE(
"MetadataRepository: batch embedding status update ignores missing hashes and duplicate inputs",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/batch_embed.txt", "batch-embed-hash");
REQUIRE((fix.repository_->insertDocument(doc).has_value()));
REQUIRE(fix.repository_->batchUpdateDocumentEmbeddingStatusByHashes({}, true, "ignored-model")
.has_value());
auto warmStats = fix.repository_->getCorpusStats();
REQUIRE((warmStats.has_value()));
CHECK((warmStats.value().embeddingCount == 0));
REQUIRE(
fix.repository_
->batchUpdateDocumentEmbeddingStatusByHashes(
{"missing-batch-hash", "batch-embed-hash", "batch-embed-hash"}, true, "batch-model")
.has_value());
auto hasEmbedding = fix.repository_->hasDocumentEmbeddingByHash("batch-embed-hash");
REQUIRE((hasEmbedding.has_value()));
CHECK((hasEmbedding.value()));
auto missingEmbedding = fix.repository_->hasDocumentEmbeddingByHash("missing-batch-hash");
REQUIRE((missingEmbedding.has_value()));
CHECK_FALSE(missingEmbedding.value());
auto storedStatus = getEmbeddingStatusRow(*fix.pool_, "batch-embed-hash");
REQUIRE((storedStatus.has_value()));
REQUIRE((storedStatus.value().has_value()));
CHECK((storedStatus.value()->hasEmbedding));
REQUIRE((storedStatus.value()->modelId.has_value()));
CHECK((storedStatus.value()->modelId.value() == "batch-model"));
auto afterEnableStats = fix.repository_->getCorpusStats();
REQUIRE((afterEnableStats.has_value()));
CHECK((afterEnableStats.value().embeddingCount == 1));
REQUIRE(fix.repository_
->batchUpdateDocumentEmbeddingStatusByHashes(
{"batch-embed-hash", "missing-batch-hash", "batch-embed-hash"}, false, "")
.has_value());
auto disabledStatus = getEmbeddingStatusRow(*fix.pool_, "batch-embed-hash");
REQUIRE((disabledStatus.has_value()));
REQUIRE((disabledStatus.value().has_value()));
CHECK_FALSE(disabledStatus.value()->hasEmbedding);
CHECK_FALSE(disabledStatus.value()->modelId.has_value());
auto afterDisableStats = fix.repository_->getCorpusStats();
REQUIRE((afterDisableStats.has_value()));
CHECK((afterDisableStats.value().embeddingCount == 0));
}
TEST_CASE("MetadataRepository: embedding completion commits embedding and repair state together",
"[unit][metadata][repository][embeddings][completion]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/complete_embed.txt", "complete-embed-hash");
auto inserted = fix.repository_->insertDocument(doc);
REQUIRE((inserted.has_value()));
REQUIRE(
fix.repository_->batchCompleteDocumentEmbeddingsByHashes({}, "ignored-model").has_value());
REQUIRE(fix.repository_
->batchCompleteDocumentEmbeddingsByHashes(
{"missing-complete-hash", "complete-embed-hash", "complete-embed-hash"},
"complete-model")
.has_value());
auto embedding = getEmbeddingStatusRow(*fix.pool_, "complete-embed-hash");
REQUIRE((embedding.has_value()));
REQUIRE((embedding.value().has_value()));
CHECK(embedding.value()->hasEmbedding);
REQUIRE((embedding.value()->modelId.has_value()));
CHECK((embedding.value()->modelId.value() == "complete-model"));
auto completed = fix.repository_->getDocument(inserted.value());
REQUIRE((completed.has_value()));
REQUIRE((completed.value().has_value()));
CHECK((completed.value()->repairStatus == RepairStatus::Completed));
CHECK((completed.value()->repairAttempts == 2));
auto missing = fix.repository_->hasDocumentEmbeddingByHash("missing-complete-hash");
REQUIRE((missing.has_value()));
CHECK_FALSE(missing.value());
auto stats = fix.repository_->getCorpusStats();
REQUIRE((stats.has_value()));
CHECK((stats.value().embeddingCount == 1));
}
TEST_CASE("MetadataRepository: reconcile empty vector-backed hash set clears embedding ownership",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/reconcile_clear_a.txt", "reconcile-clear-hash-a");
auto docB = makeDocumentWithPath("/tmp/reconcile_clear_b.txt", "reconcile-clear-hash-b");
REQUIRE((fix.repository_->insertDocument(docA).has_value()));
REQUIRE((fix.repository_->insertDocument(docB).has_value()));
REQUIRE(
fix.repository_
->updateDocumentEmbeddingStatusByHash("reconcile-clear-hash-a", true, "legacy-model")
.has_value());
REQUIRE(
fix.repository_
->updateDocumentEmbeddingStatusByHash("reconcile-clear-hash-b", true, "legacy-model")
.has_value());
REQUIRE((setEmbeddingChunkCount(*fix.pool_, "reconcile-clear-hash-a", 4).has_value()));
REQUIRE((setEmbeddingChunkCount(*fix.pool_, "reconcile-clear-hash-b", 6).has_value()));
auto beforeStats = fix.repository_->getCorpusStats();
REQUIRE((beforeStats.has_value()));
CHECK((beforeStats.value().embeddingCount == 2));
REQUIRE((fix.repository_->reconcileDocumentEmbeddingStatusByHashes({}, "").has_value()));
auto statusA = getEmbeddingStatusRow(*fix.pool_, "reconcile-clear-hash-a");
auto statusB = getEmbeddingStatusRow(*fix.pool_, "reconcile-clear-hash-b");
REQUIRE((statusA.has_value()));
REQUIRE((statusB.has_value()));
REQUIRE((statusA.value().has_value()));
REQUIRE((statusB.value().has_value()));
CHECK_FALSE(statusA.value()->hasEmbedding);
CHECK_FALSE(statusB.value()->hasEmbedding);
CHECK_FALSE(statusA.value()->modelId.has_value());
CHECK_FALSE(statusB.value()->modelId.has_value());
CHECK((statusA.value()->chunkCount == 0));
CHECK((statusB.value()->chunkCount == 0));
auto afterStats = fix.repository_->getCorpusStats();
REQUIRE((afterStats.has_value()));
CHECK((afterStats.value().embeddingCount == 0));
}
TEST_CASE("MetadataRepository: reconcile preserves chunk count and omitted model ids",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/reconcile_preserve.txt", "reconcile-preserve-hash");
REQUIRE((fix.repository_->insertDocument(doc).has_value()));
REQUIRE(
fix.repository_
->updateDocumentEmbeddingStatusByHash("reconcile-preserve-hash", true, "legacy-model")
.has_value());
REQUIRE((setEmbeddingChunkCount(*fix.pool_, "reconcile-preserve-hash", 7).has_value()));
REQUIRE(
fix.repository_->reconcileDocumentEmbeddingStatusByHashes({"reconcile-preserve-hash"}, "")
.has_value());
auto preserved = getEmbeddingStatusRow(*fix.pool_, "reconcile-preserve-hash");
REQUIRE((preserved.has_value()));
REQUIRE((preserved.value().has_value()));
CHECK((preserved.value()->hasEmbedding));
REQUIRE((preserved.value()->modelId.has_value()));
CHECK((preserved.value()->modelId.value() == "legacy-model"));
CHECK((preserved.value()->chunkCount == 7));
REQUIRE(fix.repository_
->reconcileDocumentEmbeddingStatusByHashes(
{"reconcile-preserve-hash", "reconcile-preserve-hash"}, "fresh-model")
.has_value());
auto updated = getEmbeddingStatusRow(*fix.pool_, "reconcile-preserve-hash");
REQUIRE((updated.has_value()));
REQUIRE((updated.value().has_value()));
CHECK((updated.value()->hasEmbedding));
REQUIRE((updated.value()->modelId.has_value()));
CHECK((updated.value()->modelId.value() == "fresh-model"));
CHECK((updated.value()->chunkCount == 7));
auto stats = fix.repository_->getCorpusStats();
REQUIRE((stats.has_value()));
CHECK((stats.value().embeddingCount == 1));
}
TEST_CASE("MetadataRepository: vector hash ownership ignores missing documents",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/existing_vector_a.txt", "existing-vector-hash-a");
auto docB = makeDocumentWithPath("/tmp/existing_vector_b.txt", "existing-vector-hash-b");
REQUIRE((fix.repository_->insertDocument(docA).has_value()));
REQUIRE((fix.repository_->insertDocument(docB).has_value()));
auto existing = fix.repository_->getExistingDocumentHashes(
{"existing-vector-hash-a", "orphan-vector-hash", "existing-vector-hash-b",
"existing-vector-hash-a"});
REQUIRE((existing.has_value()));
CHECK((existing.value().size() == 2));
CHECK((existing.value().contains("existing-vector-hash-a")));
CHECK((existing.value().contains("existing-vector-hash-b")));
CHECK_FALSE(existing.value().contains("orphan-vector-hash"));
}
TEST_CASE(
"MetadataRepository: embedding status update by document id tracks counts and model changes",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
auto doc = makeDocumentWithPath("/tmp/embed_by_id.txt", "embed-by-id-hash");
auto insert = fix.repository_->insertDocument(doc);
REQUIRE((insert.has_value()));
const auto docId = insert.value();
auto initialCount = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((initialCount.has_value()));
CHECK((initialCount.value() == 0));
REQUIRE((fix.repository_->updateDocumentEmbeddingStatus(docId, true, "model-a").has_value()));
auto enabled = getEmbeddingStatusRow(*fix.pool_, "embed-by-id-hash");
REQUIRE((enabled.has_value()));
REQUIRE((enabled.value().has_value()));
CHECK((enabled.value()->hasEmbedding));
REQUIRE((enabled.value()->modelId.has_value()));
CHECK((enabled.value()->modelId.value() == "model-a"));
auto afterEnableCount = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((afterEnableCount.has_value()));
CHECK((afterEnableCount.value() == 1));
auto afterEnableStats = fix.repository_->getCorpusStats();
REQUIRE((afterEnableStats.has_value()));
CHECK((afterEnableStats.value().embeddingCount == 1));
REQUIRE((fix.repository_->updateDocumentEmbeddingStatus(docId, true, "model-b").has_value()));
auto updated = getEmbeddingStatusRow(*fix.pool_, "embed-by-id-hash");
REQUIRE((updated.has_value()));
REQUIRE((updated.value().has_value()));
CHECK((updated.value()->hasEmbedding));
REQUIRE((updated.value()->modelId.has_value()));
CHECK((updated.value()->modelId.value() == "model-b"));
auto afterRetagCount = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((afterRetagCount.has_value()));
CHECK((afterRetagCount.value() == 1));
REQUIRE((fix.repository_->updateDocumentEmbeddingStatus(docId, false, "").has_value()));
auto disabled = getEmbeddingStatusRow(*fix.pool_, "embed-by-id-hash");
REQUIRE((disabled.has_value()));
REQUIRE((disabled.value().has_value()));
CHECK_FALSE(disabled.value()->hasEmbedding);
CHECK_FALSE(disabled.value()->modelId.has_value());
auto afterDisableCount = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((afterDisableCount.has_value()));
CHECK((afterDisableCount.value() == 0));
auto afterDisableStats = fix.repository_->getCorpusStats();
REQUIRE((afterDisableStats.has_value()));
CHECK((afterDisableStats.value().embeddingCount == 0));
REQUIRE((fix.repository_->updateDocumentEmbeddingStatus(docId, false, "").has_value()));
auto afterNoopDisable = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((afterNoopDisable.has_value()));
CHECK((afterNoopDisable.value() == 0));
}
TEST_CASE("MetadataRepository: embedding status update surfaces missing document identifiers",
"[unit][metadata][repository][embeddings]") {
MetadataRepositoryFixture fix;
auto missingById = fix.repository_->updateDocumentEmbeddingStatus(999999, true, "model-x");
REQUIRE_FALSE((missingById.has_value()));
CHECK((missingById.error().message.find("Document not found") != std::string::npos));
auto missingByHash =
fix.repository_->updateDocumentEmbeddingStatusByHash("missing-embed-hash", true, "model-x");
REQUIRE_FALSE((missingByHash.has_value()));
CHECK(
(missingByHash.error().message.find("Document with hash not found") != std::string::npos));
auto hasMissing = fix.repository_->hasDocumentEmbeddingByHash("missing-embed-hash");
REQUIRE((hasMissing.has_value()));
CHECK_FALSE(hasMissing.value());
auto embeddedCount = fix.repository_->getEmbeddedDocumentCount();
REQUIRE((embeddedCount.has_value()));
CHECK((embeddedCount.value() == 0));
}
TEST_CASE("MetadataRepository: repair status update increments attempts and persists state",
"[unit][metadata][repository][repair]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/repair-single.txt", "repair-single-hash");
auto insert = fix.repository_->insertDocument(doc);
REQUIRE((insert.has_value()));
const auto docId = insert.value();
REQUIRE(
(fix.repository_->updateDocumentRepairStatus("repair-single-hash", RepairStatus::Processing)
.has_value()));
auto firstRead = fix.repository_->getDocument(docId);
REQUIRE((firstRead.has_value()));
REQUIRE((firstRead.value().has_value()));
CHECK((firstRead.value()->repairStatus == RepairStatus::Processing));
CHECK((firstRead.value()->repairAttempts == 1));
CHECK((firstRead.value()->repairAttemptedAt.time_since_epoch().count() > 0));
const auto firstAttemptedAt = firstRead.value()->repairAttemptedAt.time_since_epoch().count();
REQUIRE((fix.repository_->updateDocumentRepairStatus("repair-single-hash", RepairStatus::Failed)
.has_value()));
auto secondRead = fix.repository_->getDocument(docId);
REQUIRE((secondRead.has_value()));
REQUIRE((secondRead.value().has_value()));
CHECK((secondRead.value()->repairStatus == RepairStatus::Failed));
CHECK((secondRead.value()->repairAttempts == 2));
CHECK((secondRead.value()->repairAttemptedAt.time_since_epoch().count() >= firstAttemptedAt));
}
TEST_CASE("MetadataRepository: repair status update ignores missing hash",
"[unit][metadata][repository][repair]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/repair-missing-control.txt", "repair-missing-control");
auto insert = fix.repository_->insertDocument(doc);
REQUIRE((insert.has_value()));
REQUIRE(
(fix.repository_->updateDocumentRepairStatus("repair-missing-hash", RepairStatus::Completed)
.has_value()));
auto control = fix.repository_->getDocument(insert.value());
REQUIRE((control.has_value()));
REQUIRE((control.value().has_value()));
CHECK((control.value()->repairStatus == RepairStatus::Pending));
CHECK((control.value()->repairAttempts == 0));
CHECK((control.value()->repairAttemptedAt.time_since_epoch().count() == 0));
}
TEST_CASE("MetadataRepository: batch repair status update skips missing hashes and preserves no-op "
"empty batch",
"[unit][metadata][repository][repair]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/repair-batch-a.txt", "repair-batch-hash-a");
auto docB = makeDocumentWithPath("/tmp/repair-batch-b.txt", "repair-batch-hash-b");
auto insertA = fix.repository_->insertDocument(docA);
auto insertB = fix.repository_->insertDocument(docB);
REQUIRE((insertA.has_value()));
REQUIRE((insertB.has_value()));
REQUIRE((fix.repository_->batchUpdateDocumentRepairStatuses({}, RepairStatus::Processing)
.has_value()));
auto beforeA = fix.repository_->getDocument(insertA.value());
auto beforeB = fix.repository_->getDocument(insertB.value());
REQUIRE((beforeA.has_value()));
REQUIRE((beforeA.value().has_value()));
REQUIRE((beforeB.has_value()));
REQUIRE((beforeB.value().has_value()));
CHECK((beforeA.value()->repairAttempts == 0));
CHECK((beforeB.value()->repairAttempts == 0));
REQUIRE((fix.repository_
->batchUpdateDocumentRepairStatuses(
{"repair-batch-hash-a", "repair-missing-hash", "repair-batch-hash-b"},
RepairStatus::Processing)
.has_value()));
auto afterA = fix.repository_->getDocument(insertA.value());
auto afterB = fix.repository_->getDocument(insertB.value());
REQUIRE((afterA.has_value()));
REQUIRE((afterA.value().has_value()));
REQUIRE((afterB.has_value()));
REQUIRE((afterB.value().has_value()));
CHECK((afterA.value()->repairStatus == RepairStatus::Processing));
CHECK((afterB.value()->repairStatus == RepairStatus::Processing));
CHECK((afterA.value()->repairAttempts == 1));
CHECK((afterB.value()->repairAttempts == 1));
CHECK((afterA.value()->repairAttemptedAt.time_since_epoch().count() > 0));
CHECK((afterB.value()->repairAttemptedAt.time_since_epoch().count() > 0));
}
TEST_CASE("MetadataRepository: batch repair status update preserves duplicate hash attempt counts",
"[unit][metadata][repository][repair]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/repair-duplicate.txt", "repair-duplicate-hash");
auto insert = fix.repository_->insertDocument(doc);
REQUIRE((insert.has_value()));
REQUIRE((fix.repository_
->batchUpdateDocumentRepairStatuses(
{"repair-duplicate-hash", "repair-duplicate-hash", "repair-duplicate-hash"},
RepairStatus::Processing)
.has_value()));
auto after = fix.repository_->getDocument(insert.value());
REQUIRE((after.has_value()));
REQUIRE((after.value().has_value()));
CHECK((after.value()->repairStatus == RepairStatus::Processing));
CHECK((after.value()->repairAttempts == 3));
CHECK((after.value()->repairAttemptedAt.time_since_epoch().count() > 0));
}
TEST_CASE("MetadataRepository: batch repair status update does not stack retry loops under lock",
"[unit][metadata][repository][repair][contention]") {
const auto dbPath = tempDbPath("metadata_repo_repair_lock_");
// Other contention tests intentionally increment this process-global advisory counter.
// Each case owns its observation window and must not depend on Catch2 execution order.
(void)daemon::TuneAdvisor::getAndResetDbLockErrors();
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc = makeDocumentWithPath("/tmp/repair-lock.txt", "repair-lock-hash");
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
const auto start = std::chrono::steady_clock::now();
auto result =
repository->batchUpdateDocumentRepairStatuses({"repair-lock-hash"}, RepairStatus::Failed);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((result.error().message.find("locked") != std::string::npos));
CHECK((elapsed < std::chrono::milliseconds(3000)));
const auto lockErrors = daemon::TuneAdvisor::getAndResetDbLockErrors();
INFO("reported lock errors: " << lockErrors);
CHECK((lockErrors == 2));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: getMetadataValueCounts with filters",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::system_clock::now();
auto nowSec = std::chrono::floor<std::chrono::seconds>(now);
// doc1: old document (outside filter range)
auto doc1 = makeDocumentWithPath("/tmp/a.txt", "hash-a");
doc1.createdTime = nowSec - std::chrono::hours(48);
doc1.modifiedTime = doc1.createdTime;
doc1.indexedTime = doc1.createdTime;
auto doc1Res = fix.repository_->insertDocument(doc1);
REQUIRE((doc1Res.has_value()));
// doc2: recent document (inside filter range)
auto doc2 = makeDocumentWithPath("/tmp/b.txt", "hash-b");
doc2.createdTime = nowSec;
doc2.modifiedTime = nowSec;
doc2.indexedTime = nowSec;
auto doc2Res = fix.repository_->insertDocument(doc2);
REQUIRE((doc2Res.has_value()));
// doc3: another recent document (inside filter range)
auto doc3 = makeDocumentWithPath("/tmp/c.txt", "hash-c");
doc3.createdTime = nowSec;
doc3.modifiedTime = nowSec;
doc3.indexedTime = nowSec;
auto doc3Res = fix.repository_->insertDocument(doc3);
REQUIRE((doc3Res.has_value()));
MetadataValue v1;
v1.value = "PBI-070";
v1.type = MetadataValueType::String;
// doc1 gets PBI-070 but is outside time filter
REQUIRE((fix.repository_->setMetadata(doc1Res.value(), "pbi", v1).has_value()));
// doc2 gets PBI-070 (inside time filter)
REQUIRE((fix.repository_->setMetadata(doc2Res.value(), "pbi", v1).has_value()));
MetadataValue v2;
v2.value = "PBI-071";
v2.type = MetadataValueType::String;
// doc3 gets PBI-071 (inside time filter)
REQUIRE((fix.repository_->setMetadata(doc3Res.value(), "pbi", v2).has_value()));
DocumentQueryOptions opts;
opts.modifiedAfter = std::chrono::duration_cast<std::chrono::seconds>(
(nowSec - std::chrono::hours(24)).time_since_epoch())
.count();
auto countsRes = fix.repository_->getMetadataValueCounts({"pbi"}, opts);
REQUIRE((countsRes.has_value()));
const auto& map = countsRes.value();
auto it = map.find("pbi");
REQUIRE((it != map.end()));
std::unordered_map<std::string, int64_t> counts;
for (const auto& row : it->second) {
counts[row.value] = row.count;
}
// Only doc2 (PBI-070) and doc3 (PBI-071) are in the time range
// doc1 (PBI-070) is filtered out because it's 48 hours old
REQUIRE((counts.size() == 2));
REQUIRE((counts["PBI-070"] == 1));
REQUIRE((counts["PBI-071"] == 1));
}
TEST_CASE("MetadataRepository: metadata value counts materialized view stays consistent",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/materialized_a.txt", "materialized-a");
auto docB = makeDocumentWithPath("/tmp/materialized_b.txt", "materialized-b");
auto docARes = fix.repository_->insertDocument(docA);
auto docBRes = fix.repository_->insertDocument(docB);
REQUIRE((docARes.has_value()));
REQUIRE((docBRes.has_value()));
MetadataValue v1;
v1.value = "PBI-500";
v1.type = MetadataValueType::String;
REQUIRE((fix.repository_->setMetadata(docARes.value(), "pbi", v1).has_value()));
REQUIRE((fix.repository_->setMetadata(docBRes.value(), "pbi", v1).has_value()));
DocumentQueryOptions opts;
auto countsRes = fix.repository_->getMetadataValueCounts({"pbi"}, opts);
REQUIRE((countsRes.has_value()));
auto it = countsRes.value().find("pbi");
REQUIRE((it != countsRes.value().end()));
REQUIRE((it->second.size() == 1));
CHECK((it->second.front().value == "PBI-500"));
CHECK((it->second.front().count == 2));
MetadataValue v2;
v2.value = "PBI-600";
v2.type = MetadataValueType::String;
REQUIRE((fix.repository_->setMetadata(docBRes.value(), "pbi", v2).has_value()));
countsRes = fix.repository_->getMetadataValueCounts({"pbi"}, opts);
REQUIRE((countsRes.has_value()));
it = countsRes.value().find("pbi");
REQUIRE((it != countsRes.value().end()));
std::unordered_map<std::string, int64_t> latest;
for (const auto& row : it->second) {
latest[row.value] = row.count;
}
REQUIRE((latest.size() == 2));
CHECK((latest["PBI-500"] == 1));
CHECK((latest["PBI-600"] == 1));
auto deleteResult = fix.repository_->deleteDocument(docARes.value());
REQUIRE((deleteResult.has_value()));
countsRes = fix.repository_->getMetadataValueCounts({"pbi"}, opts);
REQUIRE((countsRes.has_value()));
it = countsRes.value().find("pbi");
REQUIRE((it != countsRes.value().end()));
REQUIRE((it->second.size() == 1));
CHECK((it->second.front().value == "PBI-600"));
CHECK((it->second.front().count == 1));
}
TEST_CASE("MetadataRepository: semantic duplicate groups round-trip",
"[unit][metadata][repository][semantic-dedupe]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/semantic-a.txt", "semantic-a");
auto docB = makeDocumentWithPath("/tmp/semantic-b.txt", "semantic-b");
auto aId = fix.repository_->insertDocument(docA);
auto bId = fix.repository_->insertDocument(docB);
REQUIRE((aId.has_value()));
REQUIRE((bId.has_value()));
const auto now =
std::chrono::time_point_cast<std::chrono::seconds>(std::chrono::system_clock::now());
SemanticDuplicateGroup group;
group.groupKey = "semantic:test-group";
group.algorithmVersion = "semantic-dedupe-v1";
group.status = "suggested";
group.reviewState = "pending";
group.canonicalDocumentId = aId.value();
group.memberCount = 2;
group.maxPairScore = 0.97;
group.threshold = 0.92;
group.evidenceJson = R"({"source":"test"})";
group.createdAt = now;
group.updatedAt = now;
group.lastComputedAt = now;
auto groupId = fix.repository_->upsertSemanticDuplicateGroup(group);
REQUIRE((groupId.has_value()));
SemanticDuplicateGroupMember canonical;
canonical.documentId = aId.value();
canonical.role = "canonical";
canonical.decision = "keep";
canonical.reason = "keep-newest";
canonical.createdAt = now;
canonical.updatedAt = now;
SemanticDuplicateGroupMember duplicate;
duplicate.documentId = bId.value();
duplicate.role = "duplicate";
duplicate.similarityToCanonical = 0.98;
duplicate.titleOverlap = 0.75;
duplicate.pathOverlap = 0.25;
duplicate.pairScore = 0.93;
duplicate.decision = "unknown";
duplicate.createdAt = now;
duplicate.updatedAt = now;
REQUIRE(fix.repository_
->replaceSemanticDuplicateGroupMembers(groupId.value(), {canonical, duplicate})
.has_value());
auto fetched = fix.repository_->getSemanticDuplicateGroupByKey("semantic:test-group");
REQUIRE((fetched.has_value()));
REQUIRE((fetched.value().has_value()));
CHECK((fetched.value()->canonicalDocumentId == aId.value()));
CHECK((fetched.value()->memberCount == 2));
auto listed = fix.repository_->listSemanticDuplicateGroups();
REQUIRE((listed.has_value()));
REQUIRE_FALSE(listed.value().empty());
auto byDoc = fix.repository_->getSemanticDuplicateGroupsForDocuments(
std::array<int64_t, 2>{aId.value(), bId.value()});
REQUIRE((byDoc.has_value()));
REQUIRE((byDoc.value().contains(aId.value())));
REQUIRE((byDoc.value().contains(bId.value())));
CHECK((byDoc.value().at(aId.value()).group.groupKey == "semantic:test-group"));
CHECK((byDoc.value().at(aId.value()).members.size() == 2));
REQUIRE(fix.repository_->updateSemanticDuplicateGroupStatus("semantic:test-group", "applied")
.has_value());
auto updated = fix.repository_->getSemanticDuplicateGroupByKey("semantic:test-group");
REQUIRE((updated.has_value()));
REQUIRE((updated.value().has_value()));
CHECK((updated.value()->status == "applied"));
}
TEST_CASE("MetadataRepository: metadata value counts falls back when path FTS join fails",
"[unit][metadata][repository][fts-fallback]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/notes/todo.md", "fts-fallback-a");
auto docB = makeDocumentWithPath("/archive/todo-old.md", "fts-fallback-b");
auto aId = fix.repository_->insertDocument(docA);
auto bId = fix.repository_->insertDocument(docB);
REQUIRE((aId.has_value()));
REQUIRE((bId.has_value()));
MetadataValue ownerA;
ownerA.type = MetadataValueType::String;
ownerA.value = "alice";
REQUIRE((fix.repository_->setMetadata(aId.value(), "owner", ownerA).has_value()));
MetadataValue ownerB;
ownerB.type = MetadataValueType::String;
ownerB.value = "bob";
REQUIRE((fix.repository_->setMetadata(bId.value(), "owner", ownerB).has_value()));
// Force the FTS-join query path to fail while pathFtsAvailable_ remains true in the repository.
Database db;
REQUIRE((db.open(fix.dbPath_.string()).has_value()));
REQUIRE((db.execute("DROP TABLE IF EXISTS documents_path_fts").has_value()));
db.close();
DocumentQueryOptions fallbackOpts;
fallbackOpts.containsFragment = "todo.md";
fallbackOpts.containsUsesFts = true;
auto fallbackRes = fix.repository_->getMetadataValueCounts({"owner"}, fallbackOpts);
REQUIRE((fallbackRes.has_value()));
DocumentQueryOptions baselineOpts = fallbackOpts;
baselineOpts.containsUsesFts = false;
auto baselineRes = fix.repository_->getMetadataValueCounts({"owner"}, baselineOpts);
REQUIRE((baselineRes.has_value()));
const auto& fallbackMap = fallbackRes.value();
const auto& baselineMap = baselineRes.value();
REQUIRE((fallbackMap.contains("owner")));
REQUIRE((baselineMap.contains("owner")));
REQUIRE((fallbackMap.at("owner").size() == baselineMap.at("owner").size()));
std::unordered_map<std::string, int64_t> fallbackCounts;
std::unordered_map<std::string, int64_t> baselineCounts;
for (const auto& row : fallbackMap.at("owner")) {
fallbackCounts[row.value] = row.count;
}
for (const auto& row : baselineMap.at("owner")) {
baselineCounts[row.value] = row.count;
}
CHECK((fallbackCounts == baselineCounts));
CHECK((fallbackCounts["alice"] == 1));
CHECK_FALSE(fallbackCounts.contains("bob"));
}
TEST_CASE("MetadataRepository: get document not found", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto result = fix.repository_->getDocument(999999);
REQUIRE((result.has_value()));
CHECK_FALSE(result.value().has_value());
}
TEST_CASE("MetadataRepository: get document by hash", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "hash456";
docInfo.fileName = "hash_test.txt";
docInfo.fileSize = 2048;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto findResult = fix.repository_->getDocumentByHash("hash456");
REQUIRE((findResult.has_value()));
REQUIRE((findResult.value().has_value()));
auto foundDoc = findResult.value().value();
CHECK((foundDoc.sha256Hash == "hash456"));
CHECK((foundDoc.fileName == "hash_test.txt"));
}
TEST_CASE("MetadataRepository: update document", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "hash789";
docInfo.fileName = "original.txt";
docInfo.fileSize = 2048;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
docInfo.id = docId;
docInfo.fileName = "updated.txt";
docInfo.fileSize = 4096;
docInfo.modifiedTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
auto updateResult = fix.repository_->updateDocument(docInfo);
REQUIRE((updateResult.has_value()));
auto getResult = fix.repository_->getDocument(docId);
REQUIRE((getResult.has_value()));
REQUIRE((getResult.value().has_value()));
auto updatedDoc = getResult.value().value();
CHECK((updatedDoc.fileName == "updated.txt"));
CHECK((updatedDoc.fileSize == 4096));
}
TEST_CASE("MetadataRepository: corpus stats path depth max shrinks after update",
"[unit][metadata][repository][stats]") {
MetadataRepositoryFixture fix;
auto shallow = makeDocumentWithPath("/root.txt", "path-depth-update-shallow");
auto deep = makeDocumentWithPath("/alpha/beta/gamma/deep.txt", "path-depth-update-deep");
REQUIRE((deep.pathDepth > shallow.pathDepth));
auto shallowInsert = fix.repository_->insertDocument(shallow);
auto deepInsert = fix.repository_->insertDocument(deep);
REQUIRE((shallowInsert.has_value()));
REQUIRE((deepInsert.has_value()));
auto warmStats = fix.repository_->getCorpusStats();
REQUIRE((warmStats.has_value()));
CHECK_THAT(warmStats.value().pathDepthMax,
Catch::Matchers::WithinRel(static_cast<double>(deep.pathDepth), 0.01));
deep.id = deepInsert.value();
deep.filePath = "/flattened.txt";
deep.fileName = "flattened.txt";
deep.fileExtension = ".txt";
populatePathDerivedFields(deep);
deep.modifiedTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
REQUIRE((fix.repository_->updateDocument(deep).has_value()));
auto afterStats = fix.repository_->getCorpusStats();
REQUIRE((afterStats.has_value()));
const auto expectedMaxDepth = static_cast<double>(std::max(shallow.pathDepth, deep.pathDepth));
CHECK_THAT(afterStats.value().pathDepthMax, Catch::Matchers::WithinRel(expectedMaxDepth, 0.01));
}
TEST_CASE("MetadataRepository: query documents handles exact prefix and suffix",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath(
"/notes/todo.md", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
auto docB = makeDocumentWithPath(
"/notes/log.txt", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
auto docC = makeDocumentWithPath(
"/reports/summary.pdf", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
"application/pdf");
REQUIRE((fix.repository_->insertDocument(docA).has_value()));
REQUIRE((fix.repository_->insertDocument(docB).has_value()));
REQUIRE((fix.repository_->insertDocument(docC).has_value()));
SECTION("Exact path lookup") {
DocumentQueryOptions exactOpts;
exactOpts.exactPath = "/notes/todo.md";
auto exactRes = fix.repository_->queryDocuments(exactOpts);
REQUIRE((exactRes.has_value()));
REQUIRE((exactRes.value().size() == 1));
CHECK((exactRes.value().front().filePath == "/notes/todo.md"));
}
SECTION("Directory prefix with subdirectories") {
DocumentQueryOptions prefixOpts;
prefixOpts.pathPrefix = "/notes";
prefixOpts.prefixIsDirectory = true;
prefixOpts.includeSubdirectories = true;
auto prefixRes = fix.repository_->queryDocuments(prefixOpts);
REQUIRE((prefixRes.has_value()));
std::vector<std::string> notePaths;
for (const auto& d : prefixRes.value())
notePaths.push_back(d.filePath);
std::sort(notePaths.begin(), notePaths.end());
REQUIRE((notePaths.size() == 2));
CHECK((notePaths[0] == "/notes/log.txt"));
CHECK((notePaths[1] == "/notes/todo.md"));
}
SECTION("Contains fragment via FTS") {
DocumentQueryOptions containsOpts;
containsOpts.containsFragment = "todo.md";
containsOpts.containsUsesFts = true;
auto containsRes = fix.repository_->queryDocuments(containsOpts);
REQUIRE((containsRes.has_value()));
REQUIRE((containsRes.value().size() == 1));
CHECK((containsRes.value().front().filePath == "/notes/todo.md"));
}
SECTION("Contains fragment via FTS survives status updates and tracks path changes") {
DocumentQueryOptions todoOpts;
todoOpts.containsFragment = "todo.md";
todoOpts.containsUsesFts = true;
auto initialTodo = fix.repository_->queryDocuments(todoOpts);
REQUIRE((initialTodo.has_value()));
REQUIRE((initialTodo.value().size() == 1));
CHECK((initialTodo.value().front().filePath == "/notes/todo.md"));
auto docResult = fix.repository_->getDocumentByHash(docA.sha256Hash);
REQUIRE((docResult.has_value()));
REQUIRE((docResult.value().has_value()));
auto updated = docResult.value().value();
std::vector<ExtractionStatusUpdate> statusUpdates;
statusUpdates.push_back(
ExtractionStatusUpdate{updated.id, false, ExtractionStatus::Failed, "expected test"});
REQUIRE(
(fix.repository_->batchUpdateDocumentExtractionStatuses(statusUpdates).has_value()));
auto afterStatusUpdate = fix.repository_->queryDocuments(todoOpts);
REQUIRE((afterStatusUpdate.has_value()));
REQUIRE((afterStatusUpdate.value().size() == 1));
CHECK((afterStatusUpdate.value().front().filePath == "/notes/todo.md"));
updated.filePath = "/archive/renamed.md";
updated.fileName = "renamed.md";
updated.fileExtension = ".md";
updated.modifiedTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
populatePathDerivedFields(updated);
REQUIRE((fix.repository_->updateDocument(updated).has_value()));
auto afterRenameOld = fix.repository_->queryDocuments(todoOpts);
REQUIRE((afterRenameOld.has_value()));
CHECK((afterRenameOld.value().empty()));
DocumentQueryOptions renamedOpts;
renamedOpts.containsFragment = "renamed.md";
renamedOpts.containsUsesFts = true;
auto afterRenameNew = fix.repository_->queryDocuments(renamedOpts);
REQUIRE((afterRenameNew.has_value()));
REQUIRE((afterRenameNew.value().size() == 1));
CHECK((afterRenameNew.value().front().filePath == "/archive/renamed.md"));
}
SECTION("Extension filter") {
DocumentQueryOptions extOpts;
extOpts.extension = ".txt";
auto extRes = fix.repository_->queryDocuments(extOpts);
REQUIRE((extRes.has_value()));
REQUIRE((extRes.value().size() == 1));
CHECK((extRes.value().front().filePath == "/notes/log.txt"));
}
}
TEST_CASE("MetadataRepository: list projection matches queryDocuments for shared filters",
"[unit][metadata][repository][list-projection][parity]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath(
"/work/notes/todo.md", "1111111111111111111111111111111111111111111111111111111111111111");
auto docB = makeDocumentWithPath(
"/work/notes/log.txt", "2222222222222222222222222222222222222222222222222222222222222222");
auto docC = makeDocumentWithPath(
"/work/reports/summary.pdf",
"3333333333333333333333333333333333333333333333333333333333333333", "application/pdf");
auto docD =
makeDocumentWithPath("/archive/notes/todo-old.md",
"4444444444444444444444444444444444444444444444444444444444444444");
docA.fileSize = 10;
docB.fileSize = 20;
docC.fileSize = 30;
docD.fileSize = 40;
docB.extractionStatus = ExtractionStatus::Failed;
docB.contentExtracted = false;
auto aId = fix.repository_->insertDocument(docA);
auto bId = fix.repository_->insertDocument(docB);
auto cId = fix.repository_->insertDocument(docC);
auto dId = fix.repository_->insertDocument(docD);
REQUIRE((aId.has_value()));
REQUIRE((bId.has_value()));
REQUIRE((cId.has_value()));
REQUIRE((dId.has_value()));
auto setStringMeta = [&](int64_t docId, const std::string& key, const std::string& value) {
MetadataValue mv;
mv.type = MetadataValueType::String;
mv.value = value;
REQUIRE((fix.repository_->setMetadata(docId, key, mv).has_value()));
};
setStringMeta(aId.value(), "owner", "alice");
setStringMeta(bId.value(), "owner", "alice");
setStringMeta(cId.value(), "owner", "bob");
setStringMeta(aId.value(), "tag:notes", "notes");
setStringMeta(dId.value(), "tag:notes", "notes");
auto assertParity = [&](const DocumentQueryOptions& opts, const std::string& label) {
CAPTURE(label);
auto docsRes = fix.repository_->queryDocuments(opts);
auto projRes = fix.repository_->queryDocumentsForListProjection(opts);
REQUIRE((docsRes.has_value()));
REQUIRE((projRes.has_value()));
const auto& docs = docsRes.value();
const auto& projs = projRes.value();
REQUIRE((docs.size() == projs.size()));
for (size_t i = 0; i < docs.size(); ++i) {
CAPTURE(i);
CHECK((projs[i].id == docs[i].id));
CHECK((projs[i].filePath == docs[i].filePath));
CHECK((projs[i].fileName == docs[i].fileName));
CHECK((projs[i].fileExtension == docs[i].fileExtension));
CHECK((projs[i].fileSize == docs[i].fileSize));
CHECK((projs[i].sha256Hash == docs[i].sha256Hash));
CHECK((projs[i].mimeType == docs[i].mimeType));
CHECK((projs[i].createdTime == docs[i].createdTime));
CHECK((projs[i].modifiedTime == docs[i].modifiedTime));
CHECK((projs[i].indexedTime == docs[i].indexedTime));
CHECK((projs[i].extractionStatus == docs[i].extractionStatus));
}
};
SECTION("exact path") {
DocumentQueryOptions opts;
opts.exactPath = "/work/notes/todo.md";
assertParity(opts, "exactPath");
}
SECTION("directory prefix") {
DocumentQueryOptions opts;
opts.pathPrefix = "/work/notes";
opts.prefixIsDirectory = true;
opts.includeSubdirectories = true;
opts.orderByNameAsc = true;
assertParity(opts, "pathPrefix");
}
SECTION("contains fragment via FTS") {
DocumentQueryOptions opts;
opts.containsFragment = "todo";
opts.containsUsesFts = true;
opts.orderByNameAsc = true;
assertParity(opts, "containsFragment");
}
SECTION("combined metadata and tag filter") {
DocumentQueryOptions opts;
opts.extension = ".md";
opts.metadataFilters.push_back({"owner", "alice"});
opts.tags.push_back("notes");
opts.orderByNameAsc = true;
assertParity(opts, "metadata+tag");
}
SECTION("extraction status plus pagination") {
DocumentQueryOptions opts;
opts.pathPrefix = "/work";
opts.prefixIsDirectory = true;
opts.orderByNameAsc = true;
opts.extractionStatuses = {ExtractionStatus::Success, ExtractionStatus::Failed};
opts.limit = 2;
opts.offset = 1;
assertParity(opts, "extractionStatuses+pagination");
}
}
TEST_CASE("MetadataRepository: delete document", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "hash999";
docInfo.fileName = "delete_me.txt";
docInfo.fileSize = 512;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
auto deleteResult = fix.repository_->deleteDocument(docId);
REQUIRE((deleteResult.has_value()));
auto getResult = fix.repository_->getDocument(docId);
REQUIRE((getResult.has_value()));
CHECK_FALSE(getResult.value().has_value());
}
TEST_CASE("MetadataRepository: corpus stats path depth max shrinks after delete",
"[unit][metadata][repository][stats]") {
MetadataRepositoryFixture fix;
auto shallow = makeDocumentWithPath("/keep.txt", "path-depth-delete-shallow");
auto deep = makeDocumentWithPath("/one/two/three/four/deep.txt", "path-depth-delete-deep");
REQUIRE((deep.pathDepth > shallow.pathDepth));
auto shallowInsert = fix.repository_->insertDocument(shallow);
auto deepInsert = fix.repository_->insertDocument(deep);
REQUIRE((shallowInsert.has_value()));
REQUIRE((deepInsert.has_value()));
auto warmStats = fix.repository_->getCorpusStats();
REQUIRE((warmStats.has_value()));
CHECK_THAT(warmStats.value().pathDepthMax,
Catch::Matchers::WithinRel(static_cast<double>(deep.pathDepth), 0.01));
REQUIRE((fix.repository_->deleteDocument(deepInsert.value()).has_value()));
auto afterStats = fix.repository_->getCorpusStats();
REQUIRE((afterStats.has_value()));
CHECK_THAT(afterStats.value().pathDepthMax,
Catch::Matchers::WithinRel(static_cast<double>(shallow.pathDepth), 0.01));
}
TEST_CASE("MetadataRepository: corpus stats path depth max shrinks after batch delete",
"[unit][metadata][repository][stats]") {
MetadataRepositoryFixture fix;
auto shallow = makeDocumentWithPath("/batch/keep.txt", "path-depth-batch-shallow");
auto medium = makeDocumentWithPath("/batch/one/two/keep.txt", "path-depth-batch-medium");
auto deep = makeDocumentWithPath("/batch/one/two/three/four/deep.txt", "path-depth-batch-deep");
REQUIRE((deep.pathDepth > medium.pathDepth));
REQUIRE((medium.pathDepth > shallow.pathDepth));
auto shallowInsert = fix.repository_->insertDocument(shallow);
auto mediumInsert = fix.repository_->insertDocument(medium);
auto deepInsert = fix.repository_->insertDocument(deep);
REQUIRE((shallowInsert.has_value()));
REQUIRE((mediumInsert.has_value()));
REQUIRE((deepInsert.has_value()));
auto warmStats = fix.repository_->getCorpusStats();
REQUIRE((warmStats.has_value()));
CHECK_THAT(warmStats.value().pathDepthMax,
Catch::Matchers::WithinRel(static_cast<double>(deep.pathDepth), 0.01));
auto deleted = fix.repository_->deleteDocumentsBatch({deepInsert.value(), 999999});
REQUIRE((deleted.has_value()));
CHECK((deleted.value() == 1));
auto afterStats = fix.repository_->getCorpusStats();
REQUIRE((afterStats.has_value()));
CHECK_THAT(afterStats.value().pathDepthMax,
Catch::Matchers::WithinRel(static_cast<double>(medium.pathDepth), 0.01));
}
TEST_CASE("MetadataRepository: set and get metadata", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "metadata_test";
docInfo.fileName = "meta.txt";
docInfo.fileSize = 1024;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
MetadataValue value("Test Author");
auto setResult = fix.repository_->setMetadata(docId, "author", value);
REQUIRE((setResult.has_value()));
auto getResult = fix.repository_->getMetadata(docId, "author");
REQUIRE((getResult.has_value()));
REQUIRE((getResult.value().has_value()));
auto retrievedValue = getResult.value().value();
CHECK((retrievedValue.asString() == "Test Author"));
}
TEST_CASE("MetadataRepository: get all metadata", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "all_metadata_test";
docInfo.fileName = "meta_all.txt";
docInfo.fileSize = 1024;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
MetadataValue authorValue("John Doe");
fix.repository_->setMetadata(docId, "author", authorValue);
MetadataValue yearValue(int64_t(2024));
fix.repository_->setMetadata(docId, "year", yearValue);
MetadataValue ratingValue(4.5);
fix.repository_->setMetadata(docId, "rating", ratingValue);
auto getAllResult = fix.repository_->getAllMetadata(docId);
REQUIRE((getAllResult.has_value()));
auto metadata = getAllResult.value();
CHECK((metadata.size() == 3));
CHECK((metadata["author"].asString() == "John Doe"));
CHECK((metadata["year"].asInteger() == 2024));
CHECK_THAT(metadata["rating"].asReal(), Catch::Matchers::WithinRel(4.5, 0.0001));
}
TEST_CASE("MetadataRepository: remove metadata", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "remove_metadata_test";
docInfo.fileName = "remove.txt";
docInfo.fileSize = 1024;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
MetadataValue value("Temporary");
fix.repository_->setMetadata(docId, "temp", value);
auto getResult = fix.repository_->getMetadata(docId, "temp");
REQUIRE((getResult.has_value()));
REQUIRE((getResult.value().has_value()));
auto removeResult = fix.repository_->removeMetadata(docId, "temp");
REQUIRE((removeResult.has_value()));
getResult = fix.repository_->getMetadata(docId, "temp");
REQUIRE((getResult.has_value()));
CHECK_FALSE(getResult.value().has_value());
}
TEST_CASE("MetadataRepository: hasFtsEntry reports FTS5 presence correctly",
"[unit][metadata][repository][fts5]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "fts5_entry_check";
docInfo.fileName = "fts5check.txt";
docInfo.fileSize = 100;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
SECTION("returns false before indexing") {
auto hasFts = fix.repository_->hasFtsEntry(docId);
REQUIRE((hasFts.has_value()));
CHECK_FALSE(hasFts.value());
}
SECTION("returns true after indexing") {
auto indexResult = fix.repository_->indexDocumentContent(
docId, "Test Document", "Searchable content here", "text/plain");
REQUIRE((indexResult.has_value()));
auto hasFts = fix.repository_->hasFtsEntry(docId);
REQUIRE((hasFts.has_value()));
CHECK((hasFts.value()));
}
SECTION("returns false for non-existent document ID") {
auto hasFts = fix.repository_->hasFtsEntry(999999);
REQUIRE((hasFts.has_value()));
CHECK_FALSE(hasFts.value());
}
}
TEST_CASE("MetadataRepository: indexed count reflects actual FTS rows, not extraction success",
"[unit][metadata][repository][fts5][counts]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
DocumentInfo doc;
doc.sha256Hash = "indexed_count_fts_only_hash";
doc.fileName = "indexed_count_fts_only.txt";
doc.fileSize = 128;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
const auto docId = insertResult.value();
REQUIRE(fix.repository_->updateDocumentExtractionStatus(docId, true, ExtractionStatus::Success)
.has_value());
auto indexedWithoutFts = fix.repository_->getIndexedDocumentCount();
REQUIRE((indexedWithoutFts.has_value()));
CHECK((indexedWithoutFts.value() == 0));
CHECK((fix.repository_->getCachedIndexedCount() == 0));
CHECK((fix.repository_->getCachedExtractedCount() == 1));
DocumentContent content;
content.documentId = docId;
content.contentText = "fts count probe";
content.contentLength = static_cast<int64_t>(content.contentText.size());
content.extractionMethod = "test";
content.language = "en";
REQUIRE((fix.repository_->insertContent(content).has_value()));
REQUIRE(fix.repository_
->indexDocumentContent(docId, doc.fileName, content.contentText, "text/plain")
.has_value());
auto indexedWithFts = fix.repository_->getIndexedDocumentCount();
REQUIRE((indexedWithFts.has_value()));
CHECK((indexedWithFts.value() == 1));
CHECK((fix.repository_->getCachedIndexedCount() == 1));
}
TEST_CASE("MetadataRepository: batch extraction status updates share one transaction",
"[unit][metadata][repository][extraction]") {
MetadataRepositoryFixture fix;
auto pendingDoc = makeDocumentWithPath("/tmp/yams/batch-extraction-pending.txt",
"batch-extraction-pending-hash");
pendingDoc.contentExtracted = false;
pendingDoc.extractionStatus = ExtractionStatus::Pending;
auto pendingId = fix.repository_->insertDocument(pendingDoc);
REQUIRE((pendingId.has_value()));
auto extractedDoc = makeDocumentWithPath("/tmp/yams/batch-extraction-extracted.txt",
"batch-extraction-extracted-hash");
extractedDoc.contentExtracted = true;
extractedDoc.extractionStatus = ExtractionStatus::Success;
auto extractedId = fix.repository_->insertDocument(extractedDoc);
REQUIRE((extractedId.has_value()));
fix.repository_->initializeCounters();
CHECK((fix.repository_->getCachedExtractedCount() == 1));
std::vector<ExtractionStatusUpdate> updates;
updates.push_back(
ExtractionStatusUpdate{pendingId.value(), true, ExtractionStatus::Success, std::string{}});
updates.push_back(ExtractionStatusUpdate{extractedId.value(), false, ExtractionStatus::Failed,
"extraction failed"});
updates.push_back(
ExtractionStatusUpdate{999999, true, ExtractionStatus::Success, "missing ignored"});
auto result = fix.repository_->batchUpdateDocumentExtractionStatuses(updates);
REQUIRE((result.has_value()));
auto pendingAfter = fix.repository_->getDocument(pendingId.value());
REQUIRE((pendingAfter.has_value()));
REQUIRE((pendingAfter.value().has_value()));
CHECK((pendingAfter.value()->contentExtracted));
CHECK((pendingAfter.value()->extractionStatus == ExtractionStatus::Success));
CHECK((pendingAfter.value()->extractionError.empty()));
auto extractedAfter = fix.repository_->getDocument(extractedId.value());
REQUIRE((extractedAfter.has_value()));
REQUIRE((extractedAfter.value().has_value()));
CHECK_FALSE(extractedAfter.value()->contentExtracted);
CHECK((extractedAfter.value()->extractionStatus == ExtractionStatus::Failed));
CHECK((extractedAfter.value()->extractionError == "extraction failed"));
CHECK((fix.repository_->getCachedExtractedCount() == 1));
}
TEST_CASE("MetadataRepository: batch extraction status updates use the last update per document",
"[unit][metadata][repository][extraction]") {
MetadataRepositoryFixture fix;
auto pendingDoc =
makeDocumentWithPath("/tmp/yams/extraction-duplicate-pending.txt", "extract-dup-pending");
pendingDoc.contentExtracted = false;
pendingDoc.extractionStatus = ExtractionStatus::Pending;
auto successDoc =
makeDocumentWithPath("/tmp/yams/extraction-duplicate-success.txt", "extract-dup-success");
successDoc.contentExtracted = true;
successDoc.extractionStatus = ExtractionStatus::Success;
auto pendingId = fix.repository_->insertDocument(pendingDoc);
auto successId = fix.repository_->insertDocument(successDoc);
REQUIRE((pendingId.has_value()));
REQUIRE((successId.has_value()));
fix.repository_->initializeCounters();
CHECK((fix.repository_->getCachedExtractedCount() == 1));
std::vector<ExtractionStatusUpdate> updates;
updates.push_back(
ExtractionStatusUpdate{pendingId.value(), true, ExtractionStatus::Success, std::string{}});
updates.push_back(ExtractionStatusUpdate{pendingId.value(), false, ExtractionStatus::Failed,
"duplicate failure wins"});
updates.push_back(
ExtractionStatusUpdate{successId.value(), false, ExtractionStatus::Failed, "transient"});
updates.push_back(
ExtractionStatusUpdate{successId.value(), true, ExtractionStatus::Success, std::string{}});
REQUIRE((fix.repository_->batchUpdateDocumentExtractionStatuses(updates).has_value()));
auto pendingAfter = fix.repository_->getDocument(pendingId.value());
auto successAfter = fix.repository_->getDocument(successId.value());
REQUIRE((pendingAfter.has_value()));
REQUIRE((successAfter.has_value()));
REQUIRE((pendingAfter.value().has_value()));
REQUIRE((successAfter.value().has_value()));
CHECK_FALSE(pendingAfter.value()->contentExtracted);
CHECK((pendingAfter.value()->extractionStatus == ExtractionStatus::Failed));
CHECK((pendingAfter.value()->extractionError == "duplicate failure wins"));
CHECK((successAfter.value()->contentExtracted));
CHECK((successAfter.value()->extractionStatus == ExtractionStatus::Success));
CHECK((successAfter.value()->extractionError.empty()));
CHECK((fix.repository_->getCachedExtractedCount() == 1));
}
TEST_CASE(
"MetadataRepository: extraction status counters handle empty, negative, and missing updates",
"[unit][metadata][repository][extraction]") {
MetadataRepositoryFixture fix;
auto pendingDoc =
makeDocumentWithPath("/tmp/yams/extraction-count-pending.txt", "extract-count-pending");
pendingDoc.contentExtracted = false;
pendingDoc.extractionStatus = ExtractionStatus::Pending;
auto failedDoc =
makeDocumentWithPath("/tmp/yams/extraction-count-failed.txt", "extract-count-failed");
failedDoc.contentExtracted = false;
failedDoc.extractionStatus = ExtractionStatus::Failed;
auto successDoc =
makeDocumentWithPath("/tmp/yams/extraction-count-success.txt", "extract-count-success");
successDoc.contentExtracted = true;
successDoc.extractionStatus = ExtractionStatus::Success;
auto pendingId = fix.repository_->insertDocument(pendingDoc);
auto failedId = fix.repository_->insertDocument(failedDoc);
auto successId = fix.repository_->insertDocument(successDoc);
REQUIRE((pendingId.has_value()));
REQUIRE((failedId.has_value()));
REQUIRE((successId.has_value()));
auto pendingCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Pending);
auto failedCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Failed);
auto successCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Success);
auto skippedCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Skipped);
REQUIRE((pendingCount.has_value()));
REQUIRE((failedCount.has_value()));
REQUIRE((successCount.has_value()));
REQUIRE((skippedCount.has_value()));
CHECK((pendingCount.value() == 1));
CHECK((failedCount.value() == 1));
CHECK((successCount.value() == 1));
CHECK((skippedCount.value() == 0));
fix.repository_->initializeCounters();
CHECK((fix.repository_->getCachedExtractedCount() == 1));
REQUIRE((fix.repository_->batchUpdateDocumentExtractionStatuses({}).has_value()));
CHECK((fix.repository_->getCachedExtractedCount() == 1));
std::vector<ExtractionStatusUpdate> updates;
updates.push_back(
ExtractionStatusUpdate{-7, true, ExtractionStatus::Success, "ignored negative"});
updates.push_back(
ExtractionStatusUpdate{999999, true, ExtractionStatus::Success, "ignored missing"});
updates.push_back(
ExtractionStatusUpdate{pendingId.value(), true, ExtractionStatus::Success, std::string{}});
updates.push_back(
ExtractionStatusUpdate{failedId.value(), false, ExtractionStatus::Failed, "still failed"});
REQUIRE((fix.repository_->batchUpdateDocumentExtractionStatuses(updates).has_value()));
auto pendingAfter = fix.repository_->getDocument(pendingId.value());
auto failedAfter = fix.repository_->getDocument(failedId.value());
auto successAfter = fix.repository_->getDocument(successId.value());
REQUIRE((pendingAfter.has_value()));
REQUIRE((failedAfter.has_value()));
REQUIRE((successAfter.has_value()));
REQUIRE((pendingAfter.value().has_value()));
REQUIRE((failedAfter.value().has_value()));
REQUIRE((successAfter.value().has_value()));
CHECK((pendingAfter.value()->contentExtracted));
CHECK((pendingAfter.value()->extractionStatus == ExtractionStatus::Success));
CHECK((pendingAfter.value()->extractionError.empty()));
CHECK_FALSE(failedAfter.value()->contentExtracted);
CHECK((failedAfter.value()->extractionStatus == ExtractionStatus::Failed));
CHECK((failedAfter.value()->extractionError == "still failed"));
CHECK((successAfter.value()->contentExtracted));
CHECK((fix.repository_->getCachedExtractedCount() == 2));
auto pendingAfterCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Pending);
auto failedAfterCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Failed);
auto successAfterCount =
fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Success);
REQUIRE((pendingAfterCount.has_value()));
REQUIRE((failedAfterCount.has_value()));
REQUIRE((successAfterCount.has_value()));
CHECK((pendingAfterCount.value() == 0));
CHECK((failedAfterCount.value() == 1));
CHECK((successAfterCount.value() == 2));
auto stats = fix.repository_->getCorpusStats();
REQUIRE((stats.has_value()));
CHECK((stats.value().contentExtractedCount == 2));
}
TEST_CASE("MetadataRepository: single extraction status update does not amplify lock retries",
"[unit][metadata][repository][extraction][contention]") {
const auto dbPath = tempDbPath("metadata_repo_single_extraction_lock_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc =
makeDocumentWithPath("/tmp/single-extraction-lock.txt", "single-extraction-lock-hash");
doc.contentExtracted = false;
doc.extractionStatus = ExtractionStatus::Pending;
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
const auto start = std::chrono::steady_clock::now();
auto result = repository->updateDocumentExtractionStatus(
insert.value(), true, ExtractionStatus::Success, std::string{});
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((result.error().message.find("locked") != std::string::npos));
CHECK((elapsed < std::chrono::milliseconds(3000)));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: batch extraction status update does not amplify lock retries",
"[unit][metadata][repository][extraction][contention]") {
const auto dbPath = tempDbPath("metadata_repo_extraction_lock_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc = makeDocumentWithPath("/tmp/extraction-lock.txt", "extraction-lock-hash");
doc.contentExtracted = false;
doc.extractionStatus = ExtractionStatus::Pending;
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
const auto start = std::chrono::steady_clock::now();
auto result = repository->batchUpdateDocumentExtractionStatuses(
{{insert.value(), true, ExtractionStatus::Success, std::string{}}});
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((result.error().message.find("locked") != std::string::npos));
CHECK((elapsed < std::chrono::milliseconds(3000)));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: initializeCounters keeps extracted and indexed counts distinct",
"[unit][metadata][repository][fts5][counters]") {
MetadataRepositoryFixture fix;
DocumentInfo doc;
doc.sha256Hash = "counter_distinct_hash";
doc.fileName = "counter_distinct.txt";
doc.fileSize = 64;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
const auto docId = insertResult.value();
REQUIRE(fix.repository_->updateDocumentExtractionStatus(docId, true, ExtractionStatus::Success)
.has_value());
fix.repository_->initializeCounters();
CHECK((fix.repository_->getCachedExtractedCount() == 1));
CHECK((fix.repository_->getCachedIndexedCount() == 0));
DocumentContent content;
content.documentId = docId;
content.contentText = "counter refresh content";
content.contentLength = static_cast<int64_t>(content.contentText.size());
content.extractionMethod = "test";
content.language = "en";
REQUIRE((fix.repository_->insertContent(content).has_value()));
REQUIRE(fix.repository_
->indexDocumentContent(docId, doc.fileName, content.contentText, "text/plain")
.has_value());
auto exactIndexed = fix.repository_->getIndexedDocumentCount();
REQUIRE((exactIndexed.has_value()));
CHECK((exactIndexed.value() == 1));
CHECK((fix.repository_->getCachedExtractedCount() == 1));
}
TEST_CASE("MetadataRepository: getFts5IndexedRowIdSet returns indexed document IDs",
"[unit][metadata][repository][fts5]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
// Insert two documents
DocumentInfo doc1;
doc1.sha256Hash = "fts5_set_doc1";
doc1.fileName = "doc1.txt";
doc1.fileSize = 50;
doc1.mimeType = "text/plain";
doc1.createdTime = now;
doc1.modifiedTime = now;
auto id1Res = fix.repository_->insertDocument(doc1);
REQUIRE((id1Res.has_value()));
auto docId1 = id1Res.value();
DocumentInfo doc2;
doc2.sha256Hash = "fts5_set_doc2";
doc2.fileName = "doc2.txt";
doc2.fileSize = 60;
doc2.mimeType = "text/plain";
doc2.createdTime = now;
doc2.modifiedTime = now;
auto id2Res = fix.repository_->insertDocument(doc2);
REQUIRE((id2Res.has_value()));
auto docId2 = id2Res.value();
// Before indexing: set should be empty
auto setBefore = fix.repository_->getFts5IndexedRowIdSet();
REQUIRE((setBefore.has_value()));
CHECK((setBefore.value().count(docId1) == 0));
CHECK((setBefore.value().count(docId2) == 0));
// Index only doc1
auto indexResult = fix.repository_->indexDocumentContent(
docId1, "Doc One", "First document content", "text/plain");
REQUIRE((indexResult.has_value()));
// Set should contain only doc1
auto setAfter = fix.repository_->getFts5IndexedRowIdSet();
REQUIRE((setAfter.has_value()));
CHECK((setAfter.value().count(docId1) == 1));
CHECK((setAfter.value().count(docId2) == 0));
// Index doc2
auto indexResult2 = fix.repository_->indexDocumentContent(
docId2, "Doc Two", "Second document content", "text/plain");
REQUIRE((indexResult2.has_value()));
// Set should contain both
auto setFinal = fix.repository_->getFts5IndexedRowIdSet();
REQUIRE((setFinal.has_value()));
CHECK((setFinal.value().count(docId1) == 1));
CHECK((setFinal.value().count(docId2) == 1));
}
TEST_CASE("MetadataRepository: search functionality", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "search_test";
docInfo.fileName = "search.txt";
docInfo.fileSize = 1024;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
auto indexResult = fix.repository_->indexDocumentContent(
docId, "Test Document", "This is a test document with searchable content", "text/plain");
REQUIRE((indexResult.has_value()));
auto searchResult = fix.repository_->search("test", 10, 0);
REQUIRE((searchResult.has_value()));
auto results = searchResult.value();
CHECK((results.results.size() > 0));
}
TEST_CASE("MetadataRepository: search totalCount honors docIds filter",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto doc1 = makeDocumentWithPath("/tmp/doc_count_1.txt", "search_doc_count_1");
auto doc2 = makeDocumentWithPath("/tmp/doc_count_2.txt", "search_doc_count_2");
auto insert1 = fix.repository_->insertDocument(doc1);
auto insert2 = fix.repository_->insertDocument(doc2);
REQUIRE((insert1.has_value()));
REQUIRE((insert2.has_value()));
auto docId1 = insert1.value();
auto docId2 = insert2.value();
REQUIRE(fix.repository_
->indexDocumentContent(docId1, "Doc One", "needle token appears in doc one",
"text/plain")
.has_value());
REQUIRE(fix.repository_
->indexDocumentContent(docId2, "Doc Two", "needle token appears in doc two",
"text/plain")
.has_value());
const std::optional<std::vector<int64_t>> scopedDocIds = std::vector<int64_t>{docId1};
auto searchResult = fix.repository_->search("needle", 1, 0, scopedDocIds);
REQUIRE((searchResult.has_value()));
const auto& results = searchResult.value();
REQUIRE((results.results.size() == 1));
CHECK((results.results.front().document.id == docId1));
CHECK((results.totalCount == 1));
}
TEST_CASE("MetadataRepository: fuzzy search returns content matches",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath(
"/notes/fuzzy_content.txt",
"DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD");
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
constexpr char kRareTerm[] = "blorptastic";
std::string contentText = std::string("The ") + kRareTerm;
DocumentContent content;
content.documentId = docId;
content.contentText = contentText;
content.contentLength = static_cast<int64_t>(contentText.length());
content.extractionMethod = "test";
content.language = "en";
auto contentInsertResult = fix.repository_->insertContent(content);
REQUIRE((contentInsertResult.has_value()));
auto indexResult =
fix.repository_->indexDocumentContent(docId, doc.fileName, contentText, "text/plain");
REQUIRE((indexResult.has_value()));
auto fuzzyResult = fix.repository_->fuzzySearch(kRareTerm, 0.6f, 10);
REQUIRE((fuzzyResult.has_value()));
const auto& searchResults = fuzzyResult.value();
REQUIRE((searchResults.results.size() == 1));
CHECK((searchResults.results.front().document.id == docId));
}
TEST_CASE("MetadataRepository: fuzzy search honors minSimilarity threshold",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto exactDoc = makeDocumentWithPath(
"/notes/fuzzy_threshold_exact.txt",
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE1");
auto nearDoc = makeDocumentWithPath(
"/notes/fuzzy_threshold_near.txt",
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE2");
auto exactInsert = fix.repository_->insertDocument(exactDoc);
REQUIRE((exactInsert.has_value()));
auto nearInsert = fix.repository_->insertDocument(nearDoc);
REQUIRE((nearInsert.has_value()));
constexpr char kExactTerm[] = "fuzzthresholdtarget";
constexpr char kNearTerm[] = "fuzzthresholtarget";
REQUIRE(
fix.repository_
->indexDocumentContent(exactInsert.value(), exactDoc.fileName, kExactTerm, "text/plain")
.has_value());
REQUIRE(
fix.repository_
->indexDocumentContent(nearInsert.value(), nearDoc.fileName, kNearTerm, "text/plain")
.has_value());
REQUIRE(
(fix.repository_
->tryAddSymSpellTerms({std::pair<std::string, int64_t>{kExactTerm, 1}, {kNearTerm, 1}})
.has_value()));
auto lowThreshold = fix.repository_->fuzzySearch(kExactTerm, 0.0f, 10);
REQUIRE((lowThreshold.has_value()));
auto highThreshold = fix.repository_->fuzzySearch(kExactTerm, 1.0f, 10);
REQUIRE((highThreshold.has_value()));
auto hasDocId = [](const SearchResults& results, int64_t docId) {
return std::ranges::any_of(results.results, [docId](const SearchResult& result) {
return result.document.id == docId;
});
};
CHECK((hasDocId(lowThreshold.value(), exactInsert.value())));
CHECK((hasDocId(lowThreshold.value(), nearInsert.value())));
CHECK((hasDocId(highThreshold.value(), exactInsert.value())));
CHECK_FALSE((hasDocId(highThreshold.value(), nearInsert.value())));
CHECK((lowThreshold.value().results.size() >= highThreshold.value().results.size()));
}
TEST_CASE("MetadataRepository: keyword search does not implicitly run fuzzy fallback",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath(
"/notes/keyword_no_fuzzy_fallback.txt",
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE");
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
constexpr char kIndexedTerm[] = "blorptastic";
std::string contentText = std::string("The ") + kIndexedTerm;
DocumentContent content;
content.documentId = docId;
content.contentText = contentText;
content.contentLength = static_cast<int64_t>(contentText.length());
content.extractionMethod = "test";
content.language = "en";
REQUIRE((fix.repository_->insertContent(content).has_value()));
REQUIRE(fix.repository_->indexDocumentContent(docId, doc.fileName, contentText, "text/plain")
.has_value());
fix.repository_->addSymSpellTerm(kIndexedTerm, 1);
auto fuzzyResult = fix.repository_->fuzzySearch("blorptastik", 0.6f, 10);
REQUIRE((fuzzyResult.has_value()));
REQUIRE((fuzzyResult.value().results.size() == 1));
CHECK((fuzzyResult.value().results.front().document.id == docId));
auto keywordResult = fix.repository_->search("blorptastik", 10);
REQUIRE((keywordResult.has_value()));
CHECK((keywordResult.value().results.empty()));
CHECK((keywordResult.value().totalCount == 0));
}
TEST_CASE(
"MetadataRepository: concurrent SymSpell writes use coordinator without blocking fuzzy reads",
"[unit][metadata][repository][symspell]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath(
"/notes/symspell_lock_storm.txt",
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEF");
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
const auto docId = insertResult.value();
const std::string indexedTerm = "symstormtarget";
const std::string contentText = std::string("The ") + indexedTerm;
REQUIRE((fix.repository_->indexDocumentContent(docId, doc.fileName, contentText, "text/plain")
.has_value()));
boost::asio::io_context io;
yams::daemon::WriteCoordinator::Config config;
config.maxBatchSize = 8;
config.maxBatchDelayMs = std::chrono::milliseconds{1};
config.channelCapacity = 512;
auto repoRef =
std::shared_ptr<MetadataRepository>(fix.repository_.get(), [](MetadataRepository*) {});
yams::daemon::WriteCoordinator coordinator(io, {}, repoRef, config);
coordinator.start();
std::thread writerLoop([&io] { io.run(); });
std::atomic<bool> failed{false};
std::vector<std::thread> workers;
for (int worker = 0; worker < 4; ++worker) {
workers.emplace_back([&coordinator, worker, indexedTerm] {
for (int i = 0; i < 50; ++i) {
(void)worker;
(void)i;
coordinator.enqueue(makeSymSpellBatch("test/symspell_lock_storm", indexedTerm));
}
});
}
for (int worker = 0; worker < 4; ++worker) {
workers.emplace_back([&] {
for (int i = 0; i < 50; ++i) {
auto result = fix.repository_->fuzzySearch(indexedTerm, 0.6f, 5);
if (!result || result.value().results.empty()) {
failed.store(true, std::memory_order_relaxed);
return;
}
}
});
}
for (auto& worker : workers) {
worker.join();
}
auto flushResult = coordinator.flush(std::chrono::seconds{10});
coordinator.shutdown();
io.stop();
if (writerLoop.joinable()) {
writerLoop.join();
}
REQUIRE((flushResult.has_value()));
CHECK_FALSE((failed.load(std::memory_order_relaxed)));
CHECK((coordinator.getStats().symSpellTermsAdded >= 200));
auto fuzzyResult = fix.repository_->fuzzySearch("symstormtargat", 0.6f, 10);
REQUIRE((fuzzyResult.has_value()));
REQUIRE((fuzzyResult.value().results.size() == 1));
CHECK((fuzzyResult.value().results.front().document.id == docId));
}
TEST_CASE("MetadataRepository: coordinator coalesces duplicate SymSpell terms preserving "
"frequency",
"[unit][metadata][repository][symspell]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath(
"/notes/symspell_coalesce.txt",
"EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE70");
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
const auto docId = insertResult.value();
const std::string indexedTerm = "coalescestormtarget";
const std::string contentText = std::string("The ") + indexedTerm;
REQUIRE((fix.repository_->indexDocumentContent(docId, doc.fileName, contentText, "text/plain")
.has_value()));
boost::asio::io_context io;
yams::daemon::WriteCoordinator::Config config;
config.maxBatchSize = 8;
config.maxBatchDelayMs = std::chrono::milliseconds{1};
config.channelCapacity = 512;
auto repoRef =
std::shared_ptr<MetadataRepository>(fix.repository_.get(), [](MetadataRepository*) {});
yams::daemon::WriteCoordinator coordinator(io, {}, repoRef, config);
coordinator.start();
std::thread writerLoop([&io] { io.run(); });
constexpr std::uint64_t kDuplicateBatches = 300;
for (std::uint64_t i = 0; i < kDuplicateBatches; ++i) {
coordinator.enqueue(makeSymSpellBatch("test/symspell_coalesce", indexedTerm));
}
auto flushResult = coordinator.flush(std::chrono::seconds{10});
coordinator.shutdown();
io.stop();
if (writerLoop.joinable()) {
writerLoop.join();
}
REQUIRE((flushResult.has_value()));
CHECK((coordinator.getStats().symSpellTermsAdded == kDuplicateBatches));
auto fuzzyResult = fix.repository_->fuzzySearch("coalescestormtargat", 0.6f, 10);
REQUIRE((fuzzyResult.has_value()));
REQUIRE((fuzzyResult.value().results.size() == 1));
CHECK((fuzzyResult.value().results.front().document.id == docId));
}
TEST_CASE("MetadataRepository: SymSpell terms spanning write chunk boundaries are all indexed",
"[unit][metadata][repository][symspell]") {
MetadataRepositoryFixture fix;
struct Target {
std::string term;
std::string typo;
std::string path;
std::string hashSuffix;
int64_t docId = 0;
};
std::vector<Target> targets = {
{"alphachunkzero", "alphachunkzera", "/notes/chunk_alpha.txt", "71", 0},
{"betachunkmiddle", "betachunkmiddel", "/notes/chunk_beta.txt", "72", 0},
{"gammachunklast", "gammachunklasd", "/notes/chunk_gamma.txt", "73", 0},
};
for (auto& target : targets) {
auto doc = makeDocumentWithPath(
target.path,
std::string("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE") +
target.hashSuffix);
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
target.docId = insertResult.value();
const std::string contentText = std::string("The ") + target.term;
REQUIRE((fix.repository_
->indexDocumentContent(target.docId, doc.fileName, contentText, "text/plain")
.has_value()));
}
constexpr std::size_t kTotalTerms = 520;
std::vector<std::pair<std::string, int64_t>> termPairs;
termPairs.reserve(kTotalTerms);
for (std::size_t i = 0; i < kTotalTerms; ++i) {
termPairs.emplace_back("fillerchunkterm" + std::to_string(i), 1);
}
termPairs[0] = {targets[0].term, 1};
termPairs[511] = {targets[1].term, 1};
termPairs[519] = {targets[2].term, 1};
auto addTermsResult = fix.repository_->tryAddSymSpellTerms(termPairs);
REQUIRE((addTermsResult.has_value()));
for (const auto& target : targets) {
auto fuzzyResult = fix.repository_->fuzzySearch(target.typo, 0.6f, 10);
REQUIRE((fuzzyResult.has_value()));
REQUIRE((fuzzyResult.value().results.size() == 1));
CHECK((fuzzyResult.value().results.front().document.id == target.docId));
}
}
TEST_CASE("MetadataRepository: search sanitizes snippet UTF-8", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
DocumentInfo docInfo;
docInfo.sha256Hash = "search_bad_utf8";
docInfo.fileName = "bad_utf8.txt";
docInfo.fileSize = 32;
docInfo.mimeType = "text/plain";
docInfo.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
docInfo.modifiedTime = docInfo.createdTime;
auto createResult = fix.repository_->insertDocument(docInfo);
REQUIRE((createResult.has_value()));
auto docId = createResult.value();
std::string badContent = "alpha";
badContent.push_back(static_cast<char>(0xFF));
badContent += "beta";
auto indexResult =
fix.repository_->indexDocumentContent(docId, "Bad UTF-8", badContent, "text/plain");
REQUIRE((indexResult.has_value()));
auto searchResult = fix.repository_->search("alpha", 10, 0);
REQUIRE((searchResult.has_value()));
const auto& results = searchResult.value();
REQUIRE_FALSE(results.results.empty());
bool matchedDocument = false;
for (const auto& item : results.results) {
if (item.document.sha256Hash == "search_bad_utf8") {
matchedDocument = true;
auto sanitized = common::sanitizeUtf8(item.snippet);
CHECK((item.snippet == sanitized));
CHECK((item.snippet.find(static_cast<char>(0xFF)) == std::string::npos));
}
}
CHECK((matchedDocument));
}
TEST_CASE("MetadataRepository: counts and modified since", "[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
using clock = std::chrono::system_clock;
auto now = std::chrono::floor<std::chrono::seconds>(clock::now());
auto d1 = makeDocumentWithPath("/tmp/a.txt", "H-A");
d1.extractionStatus = ExtractionStatus::Success;
d1.contentExtracted = true;
d1.modifiedTime = now - std::chrono::hours(2);
auto d2 = makeDocumentWithPath("/tmp/b.txt", "H-B");
d2.extractionStatus = ExtractionStatus::Pending;
d2.contentExtracted = false;
d2.modifiedTime = now - std::chrono::seconds(10);
auto d3 = makeDocumentWithPath("/tmp/c.txt", "H-C");
d3.extractionStatus = ExtractionStatus::Failed;
d3.contentExtracted = true;
d3.modifiedTime = now - std::chrono::hours(3);
REQUIRE((fix.repository_->insertDocument(d1).has_value()));
REQUIRE((fix.repository_->insertDocument(d2).has_value()));
REQUIRE((fix.repository_->insertDocument(d3).has_value()));
auto total = fix.repository_->getDocumentCount();
REQUIRE((total.has_value()));
CHECK((total.value() >= 3));
auto extracted = fix.repository_->getContentExtractedDocumentCount();
REQUIRE((extracted.has_value()));
CHECK((extracted.value() >= 2));
auto c_pending = fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Pending);
auto c_success = fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Success);
auto c_failed = fix.repository_->getDocumentCountByExtractionStatus(ExtractionStatus::Failed);
REQUIRE((c_pending.has_value()));
REQUIRE((c_success.has_value()));
REQUIRE((c_failed.has_value()));
CHECK((c_pending.value() >= 1));
CHECK((c_success.value() >= 1));
CHECK((c_failed.value() >= 1));
auto since = clock::now() - std::chrono::seconds(60);
auto modifiedRes = fix.repository_->findDocumentsModifiedSince(since);
REQUIRE((modifiedRes.has_value()));
bool foundRecent = false;
for (const auto& doc : modifiedRes.value()) {
if (doc.sha256Hash == d2.sha256Hash) {
foundRecent = true;
break;
}
}
CHECK((foundRecent));
}
TEST_CASE("MetadataRepository: path tree upsert creates nodes and counts",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto docInfo = makeDocumentWithPath(
"/src/example.txt", "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD");
auto docInsert = fix.repository_->insertDocument(docInfo);
REQUIRE((docInsert.has_value()));
auto docId = docInsert.value();
docInfo.id = docId;
auto upsert = fix.repository_->upsertPathTreeForDocument(docInfo, docId, true, {});
REQUIRE((upsert.has_value()));
auto lookup = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src");
REQUIRE((lookup.has_value()));
REQUIRE((lookup.value().has_value()));
auto node = lookup.value().value();
CHECK((node.fullPath == "/src"));
CHECK((node.docCount == 1));
auto repeat = fix.repository_->upsertPathTreeForDocument(docInfo, docId, false, {});
REQUIRE((repeat.has_value()));
auto afterRepeat = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src");
REQUIRE((afterRepeat.has_value()));
REQUIRE((afterRepeat.value().has_value()));
CHECK((afterRepeat.value()->docCount == 1));
auto fullLookup = fix.repository_->findPathTreeNodeByFullPath("/src/example.txt");
REQUIRE((fullLookup.has_value()));
REQUIRE((fullLookup.value().has_value()));
CHECK((fullLookup.value()->fullPath == "/src/example.txt"));
}
TEST_CASE("MetadataRepository: path tree centroid accumulates embeddings",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto docInfo = makeDocumentWithPath(
"/src/lib/foo.cpp", "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE");
auto docInsert = fix.repository_->insertDocument(docInfo);
REQUIRE((docInsert.has_value()));
auto docId = docInsert.value();
docInfo.id = docId;
REQUIRE((fix.repository_->upsertPathTreeForDocument(docInfo, docId, true, {}).has_value()));
std::vector<float> embedding{1.0F, 2.0F, 3.0F};
REQUIRE(
fix.repository_
->upsertPathTreeForDocument(docInfo, docId, false,
std::span<const float>(embedding.data(), embedding.size()))
.has_value());
auto node = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "src");
REQUIRE((node.has_value()));
REQUIRE((node.value().has_value()));
CHECK((node.value()->centroidWeight == 1));
}
TEST_CASE("MetadataRepository: path tree upsert rolls back partial changes on failure",
"[unit][metadata][repository][path-tree][atomicity]") {
MetadataRepositoryFixture fix;
auto docInfo = makeDocumentWithPath(
"/atomic/lib/file.txt", "ABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAB");
auto docInsert = fix.repository_->insertDocument(docInfo);
REQUIRE((docInsert.has_value()));
auto docId = docInsert.value();
docInfo.id = docId;
auto installTrigger = fix.pool_->withConnection([&](Database& db) -> Result<void> {
return db.execute("CREATE TRIGGER abort_path_tree_upsert_leaf_centroid "
"BEFORE UPDATE OF centroid_weight ON path_tree_nodes "
"WHEN NEW.full_path = '/atomic/lib/file.txt' "
"AND NEW.centroid_weight = OLD.centroid_weight + 1 "
"BEGIN "
"SELECT RAISE(ABORT, 'injected path tree upsert failure'); "
"END");
});
REQUIRE((installTrigger.has_value()));
std::vector<float> embedding{1.0F, 2.0F, 3.0F};
auto upsert = fix.repository_->upsertPathTreeForDocument(
docInfo, docId, true, std::span<const float>(embedding.data(), embedding.size()));
REQUIRE_FALSE((upsert.has_value()));
auto rootNode = fix.repository_->findPathTreeNodeByFullPath("/atomic");
REQUIRE((rootNode.has_value()));
CHECK_FALSE((rootNode.value().has_value()));
auto libNode = fix.repository_->findPathTreeNodeByFullPath("/atomic/lib");
REQUIRE((libNode.has_value()));
CHECK_FALSE((libNode.value().has_value()));
auto leafNode = fix.repository_->findPathTreeNodeByFullPath("/atomic/lib/file.txt");
REQUIRE((leafNode.has_value()));
CHECK_FALSE((leafNode.value().has_value()));
auto assocCount = fix.pool_->withConnection([&](Database& db) -> Result<int64_t> {
auto stmtResult =
db.prepare("SELECT COUNT(*) FROM path_tree_node_documents WHERE document_id = ?");
if (!stmtResult) {
return stmtResult.error();
}
auto stmt = std::move(stmtResult).value();
if (auto bindDoc = stmt.bind(1, docId); !bindDoc) {
return bindDoc.error();
}
auto stepResult = stmt.step();
if (!stepResult) {
return stepResult.error();
}
if (!stepResult.value()) {
return int64_t{0};
}
return stmt.getInt64(0);
});
REQUIRE((assocCount.has_value()));
CHECK((assocCount.value() == 0));
}
TEST_CASE("MetadataRepository: remove path tree decrements counts and deletes empty nodes",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto docInfo =
makeDocumentWithPath("/project/src/lib/util.cpp",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
auto docInsert = fix.repository_->insertDocument(docInfo);
REQUIRE((docInsert.has_value()));
auto docId = docInsert.value();
docInfo.id = docId;
std::vector<float> embedding{1.0F, 2.0F, 3.0F};
auto upsert = fix.repository_->upsertPathTreeForDocument(
docInfo, docId, true, std::span<const float>(embedding.data(), embedding.size()));
REQUIRE((upsert.has_value()));
auto leafNode = fix.repository_->findPathTreeNodeByFullPath("/project/src/lib/util.cpp");
REQUIRE((leafNode.has_value()));
REQUIRE((leafNode.value().has_value()));
CHECK((leafNode.value()->docCount == 1));
CHECK((leafNode.value()->centroidWeight == 1));
auto libNode = fix.repository_->findPathTreeNodeByFullPath("/project/src/lib");
REQUIRE((libNode.has_value()));
REQUIRE((libNode.value().has_value()));
CHECK((libNode.value()->docCount == 1));
auto remove = fix.repository_->removePathTreeForDocument(
docInfo, docId, std::span<const float>(embedding.data(), embedding.size()));
REQUIRE((remove.has_value()));
auto afterRemoveLeaf = fix.repository_->findPathTreeNodeByFullPath("/project/src/lib/util.cpp");
REQUIRE((afterRemoveLeaf.has_value()));
CHECK_FALSE(afterRemoveLeaf.value().has_value());
auto afterRemoveLib = fix.repository_->findPathTreeNodeByFullPath("/project/src/lib");
REQUIRE((afterRemoveLib.has_value()));
CHECK_FALSE(afterRemoveLib.value().has_value());
}
TEST_CASE("MetadataRepository: remove path tree recalculates centroid",
"[unit][metadata][repository]") {
MetadataRepositoryFixture fix;
auto doc1 = makeDocumentWithPath(
"/shared/file1.txt", "1111111111111111111111111111111111111111111111111111111111111111");
auto doc1Insert = fix.repository_->insertDocument(doc1);
REQUIRE((doc1Insert.has_value()));
doc1.id = doc1Insert.value();
auto doc2 = makeDocumentWithPath(
"/shared/file2.txt", "2222222222222222222222222222222222222222222222222222222222222222");
auto doc2Insert = fix.repository_->insertDocument(doc2);
REQUIRE((doc2Insert.has_value()));
doc2.id = doc2Insert.value();
std::vector<float> emb1{1.0F, 0.0F, 0.0F};
std::vector<float> emb2{0.0F, 1.0F, 0.0F};
auto upsert1 = fix.repository_->upsertPathTreeForDocument(
doc1, doc1.id, true, std::span<const float>(emb1.data(), emb1.size()));
REQUIRE((upsert1.has_value()));
auto upsert2 = fix.repository_->upsertPathTreeForDocument(
doc2, doc2.id, true, std::span<const float>(emb2.data(), emb2.size()));
REQUIRE((upsert2.has_value()));
auto sharedNode = fix.repository_->findPathTreeNodeByFullPath("/shared");
REQUIRE((sharedNode.has_value()));
REQUIRE((sharedNode.value().has_value()));
CHECK((sharedNode.value()->docCount == 2));
CHECK((sharedNode.value()->centroidWeight == 2));
auto remove1 = fix.repository_->removePathTreeForDocument(
doc1, doc1.id, std::span<const float>(emb1.data(), emb1.size()));
REQUIRE((remove1.has_value()));
auto afterRemove = fix.repository_->findPathTreeNodeByFullPath("/shared");
REQUIRE((afterRemove.has_value()));
REQUIRE((afterRemove.value().has_value()));
CHECK((afterRemove.value()->docCount == 1));
CHECK((afterRemove.value()->centroidWeight == 1));
}
TEST_CASE("MetadataRepository: path tree child listing and repair helpers",
"[unit][metadata][repository][path-tree]") {
MetadataRepositoryFixture fix;
auto missingDoc = makeDocumentWithPath("/repair/missing.txt", "path-tree-missing-hash");
auto treeDocA = makeDocumentWithPath("/repair/tree/a.txt", "path-tree-a-hash");
auto treeDocB = makeDocumentWithPath("/repair/tree/b.txt", "path-tree-b-hash");
auto missingId = fix.repository_->insertDocument(missingDoc);
auto treeAId = fix.repository_->insertDocument(treeDocA);
auto treeBId = fix.repository_->insertDocument(treeDocB);
REQUIRE((missingId.has_value()));
REQUIRE((treeAId.has_value()));
REQUIRE((treeBId.has_value()));
treeDocA.id = treeAId.value();
treeDocB.id = treeBId.value();
REQUIRE(
fix.repository_->upsertPathTreeForDocument(treeDocA, treeDocA.id, true, {}).has_value());
REQUIRE(
fix.repository_->upsertPathTreeForDocument(treeDocB, treeDocB.id, true, {}).has_value());
auto missingCount = fix.repository_->countDocsMissingPathTree();
REQUIRE((missingCount.has_value()));
CHECK((missingCount.value() == 1));
auto missingDocs = fix.repository_->findDocsMissingPathTree(5);
REQUIRE((missingDocs.has_value()));
REQUIRE((missingDocs.value().size() == 1));
CHECK((missingDocs.value().front().sha256Hash == missingDoc.sha256Hash));
auto rootChildren = fix.repository_->listPathTreeChildren("/", 10);
REQUIRE((rootChildren.has_value()));
auto repairIt =
std::find_if(rootChildren.value().begin(), rootChildren.value().end(),
[](const PathTreeNode& node) { return node.fullPath == "/repair"; });
REQUIRE((repairIt != rootChildren.value().end()));
CHECK((repairIt->docCount == 2));
auto repairChildren = fix.repository_->listPathTreeChildren("/repair", 10);
REQUIRE((repairChildren.has_value()));
REQUIRE((repairChildren.value().size() == 1));
CHECK((repairChildren.value().front().fullPath == "/repair/tree"));
CHECK((repairChildren.value().front().docCount == 2));
auto limitedChildren = fix.repository_->listPathTreeChildren("/repair/tree", 1);
REQUIRE((limitedChildren.has_value()));
REQUIRE((limitedChildren.value().size() == 1));
CHECK((limitedChildren.value().front().fullPath == "/repair/tree/a.txt"));
auto missingParentChildren = fix.repository_->listPathTreeChildren("/repair/missing.txt", 10);
REQUIRE((missingParentChildren.has_value()));
CHECK((missingParentChildren.value().empty()));
auto singleConnDbPath = tempDbPath("metadata_repo_single_conn_path_tree_");
ConnectionPoolConfig singleConnConfig;
singleConnConfig.minConnections = 1;
singleConnConfig.maxConnections = 1;
auto singleConnPool =
std::make_unique<ConnectionPool>(singleConnDbPath.string(), singleConnConfig);
REQUIRE((singleConnPool->initialize().has_value()));
auto singleConnRepo = std::make_unique<MetadataRepository>(
*singleConnPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto singleConnDoc =
makeDocumentWithPath("/single/connection/child.txt", "single-connection-path-tree-hash");
auto singleConnInsert = singleConnRepo->insertDocument(singleConnDoc);
REQUIRE((singleConnInsert.has_value()));
singleConnDoc.id = singleConnInsert.value();
REQUIRE((singleConnRepo->upsertPathTreeForDocument(singleConnDoc, singleConnDoc.id, true, {})
.has_value()));
auto singleConnChildren = singleConnRepo->listPathTreeChildren("/single/connection", 10);
REQUIRE((singleConnChildren.has_value()));
REQUIRE((singleConnChildren.value().size() == 1));
CHECK((singleConnChildren.value().front().fullPath == "/single/connection/child.txt"));
singleConnRepo.reset();
singleConnPool->shutdown();
singleConnPool.reset();
{
std::error_code ec;
std::filesystem::remove(singleConnDbPath, ec);
}
auto prefixDocs = fix.repository_->findDocumentsByPathTreePrefix("/repair/tree", true, 10);
REQUIRE((prefixDocs.has_value()));
std::vector<std::string> hashes;
for (const auto& doc : prefixDocs.value()) {
hashes.push_back(doc.sha256Hash);
}
CHECK((std::find(hashes.begin(), hashes.end(), treeDocA.sha256Hash) != hashes.end()));
CHECK((std::find(hashes.begin(), hashes.end(), treeDocB.sha256Hash) != hashes.end()));
CHECK((std::find(hashes.begin(), hashes.end(), missingDoc.sha256Hash) == hashes.end()));
}
TEST_CASE("MetadataRepository: direct path tree helpers round-trip",
"[unit][metadata][repository][path-tree][helpers]") {
MetadataRepositoryFixture fix;
auto rootNode =
fix.repository_->insertPathTreeNode(PathTreeNode::kNullParent, "manual", "/manual");
REQUIRE((rootNode.has_value()));
CHECK((rootNode.value().fullPath == "/manual"));
CHECK((rootNode.value().parentId == PathTreeNode::kNullParent));
auto childNode =
fix.repository_->insertPathTreeNode(rootNode.value().id, "child", "/manual/child");
REQUIRE((childNode.has_value()));
CHECK((childNode.value().parentId == rootNode.value().id));
CHECK((childNode.value().fullPath == "/manual/child"));
auto rootLookup = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "manual");
REQUIRE((rootLookup.has_value()));
REQUIRE((rootLookup.value().has_value()));
CHECK((rootLookup.value()->id == rootNode.value().id));
auto childLookup = fix.repository_->findPathTreeNode(rootNode.value().id, "child");
REQUIRE((childLookup.has_value()));
REQUIRE((childLookup.value().has_value()));
CHECK((childLookup.value()->id == childNode.value().id));
auto missingLookup = fix.repository_->findPathTreeNode(rootNode.value().id, "missing");
REQUIRE((missingLookup.has_value()));
CHECK_FALSE((missingLookup.value().has_value()));
auto emptyPathLookup = fix.repository_->findPathTreeNodeByFullPath("");
REQUIRE((emptyPathLookup.has_value()));
CHECK_FALSE((emptyPathLookup.value().has_value()));
auto doc = makeDocumentWithPath("/manual/document.txt", "path-tree-helper-doc");
auto docId = fix.repository_->insertDocument(doc);
REQUIRE((docId.has_value()));
REQUIRE((fix.repository_->incrementPathTreeDocCount(rootNode.value().id, docId.value())
.has_value()));
REQUIRE((fix.repository_->incrementPathTreeDocCount(rootNode.value().id, docId.value())
.has_value()));
auto rootAfterIncrement = fix.repository_->findPathTreeNodeByFullPath("/manual");
REQUIRE((rootAfterIncrement.has_value()));
REQUIRE((rootAfterIncrement.value().has_value()));
CHECK((rootAfterIncrement.value()->docCount == 1));
auto rootChildrenNoLimit = fix.repository_->listPathTreeChildren("", 0);
REQUIRE((rootChildrenNoLimit.has_value()));
auto manualIt =
std::find_if(rootChildrenNoLimit.value().begin(), rootChildrenNoLimit.value().end(),
[](const PathTreeNode& node) { return node.fullPath == "/manual"; });
REQUIRE((manualIt != rootChildrenNoLimit.value().end()));
CHECK((manualIt->docCount == 1));
}
TEST_CASE("MetadataRepository: direct path tree centroid helper handles reset and missing node",
"[unit][metadata][repository][path-tree][helpers]") {
MetadataRepositoryFixture fix;
auto node = fix.repository_->insertPathTreeNode(PathTreeNode::kNullParent, "embed", "/embed");
REQUIRE((node.has_value()));
REQUIRE((fix.repository_->accumulatePathTreeCentroid(node.value().id, std::span<const float>{})
.has_value()));
auto afterEmpty = fix.repository_->findPathTreeNodeByFullPath("/embed");
REQUIRE((afterEmpty.has_value()));
REQUIRE((afterEmpty.value().has_value()));
CHECK((afterEmpty.value()->centroidWeight == 0));
CHECK((afterEmpty.value()->centroid.empty()));
const std::vector<float> firstEmbedding{1.0F, 2.0F, 3.0F};
REQUIRE((fix.repository_
->accumulatePathTreeCentroid(
node.value().id,
std::span<const float>(firstEmbedding.data(), firstEmbedding.size()))
.has_value()));
auto afterFirst = fix.repository_->findPathTreeNodeByFullPath("/embed");
REQUIRE((afterFirst.has_value()));
REQUIRE((afterFirst.value().has_value()));
CHECK((afterFirst.value()->centroidWeight == 1));
CHECK((afterFirst.value()->centroid == firstEmbedding));
const std::vector<float> secondEmbedding{4.0F, 5.0F, 6.0F};
REQUIRE((fix.repository_
->accumulatePathTreeCentroid(
node.value().id,
std::span<const float>(secondEmbedding.data(), secondEmbedding.size()))
.has_value()));
auto afterSecond = fix.repository_->findPathTreeNodeByFullPath("/embed");
REQUIRE((afterSecond.has_value()));
REQUIRE((afterSecond.value().has_value()));
CHECK((afterSecond.value()->centroidWeight == 2));
REQUIRE((afterSecond.value()->centroid.size() == 3));
CHECK_THAT(afterSecond.value()->centroid[0], Catch::Matchers::WithinRel(2.5F, 0.01F));
CHECK_THAT(afterSecond.value()->centroid[1], Catch::Matchers::WithinRel(3.5F, 0.01F));
CHECK_THAT(afterSecond.value()->centroid[2], Catch::Matchers::WithinRel(4.5F, 0.01F));
const std::vector<std::byte> corruptBlob{std::byte{0x01}, std::byte{0x02}, std::byte{0x03}};
REQUIRE((overwritePathTreeCentroid(*fix.pool_, "/embed", corruptBlob, 5).has_value()));
const std::vector<float> resetEmbedding{9.0F, 10.0F, 11.0F};
REQUIRE((fix.repository_
->accumulatePathTreeCentroid(
node.value().id,
std::span<const float>(resetEmbedding.data(), resetEmbedding.size()))
.has_value()));
auto afterReset = fix.repository_->findPathTreeNodeByFullPath("/embed");
REQUIRE((afterReset.has_value()));
REQUIRE((afterReset.value().has_value()));
CHECK((afterReset.value()->centroidWeight == 1));
CHECK((afterReset.value()->centroid == resetEmbedding));
auto missingNodeResult = fix.repository_->accumulatePathTreeCentroid(
999999, std::span<const float>(resetEmbedding.data(), resetEmbedding.size()));
REQUIRE_FALSE((missingNodeResult.has_value()));
CHECK((missingNodeResult.error().message.find("not found") != std::string::npos));
}
TEST_CASE("MetadataRepository: path tree supports relative document paths",
"[unit][metadata][repository][path-tree][relative]") {
MetadataRepositoryFixture fix;
auto storedDoc = makeDocumentWithPath("/backing/relative-source.txt", "path-tree-relative-doc");
auto docInsert = fix.repository_->insertDocument(storedDoc);
REQUIRE((docInsert.has_value()));
storedDoc.id = docInsert.value();
auto relativePathInfo = storedDoc;
relativePathInfo.filePath = "relative/tree/file.txt";
REQUIRE((fix.repository_->upsertPathTreeForDocument(relativePathInfo, storedDoc.id, true, {})
.has_value()));
auto relativeRoot = fix.repository_->findPathTreeNode(PathTreeNode::kNullParent, "relative");
REQUIRE((relativeRoot.has_value()));
REQUIRE((relativeRoot.value().has_value()));
CHECK((relativeRoot.value()->fullPath == "relative"));
CHECK((relativeRoot.value()->docCount == 1));
auto relativeChild = fix.repository_->findPathTreeNodeByFullPath("relative/tree");
REQUIRE((relativeChild.has_value()));
REQUIRE((relativeChild.value().has_value()));
CHECK((relativeChild.value()->docCount == 1));
auto relativeChildren = fix.repository_->listPathTreeChildren("relative", 0);
REQUIRE((relativeChildren.has_value()));
REQUIRE((relativeChildren.value().size() == 1));
CHECK((relativeChildren.value().front().fullPath == "relative/tree"));
REQUIRE((fix.repository_->removePathTreeForDocument(relativePathInfo, storedDoc.id, {})
.has_value()));
auto removedLeaf = fix.repository_->findPathTreeNodeByFullPath("relative/tree/file.txt");
REQUIRE((removedLeaf.has_value()));
CHECK_FALSE((removedLeaf.value().has_value()));
auto preservedRoot = fix.repository_->findPathTreeNodeByFullPath("relative");
REQUIRE((preservedRoot.has_value()));
REQUIRE((preservedRoot.value().has_value()));
CHECK((preservedRoot.value()->docCount == 0));
}
TEST_CASE("MetadataRepository: tree snapshots and changes round-trip",
"[unit][metadata][repository][tree-diff]") {
MetadataRepositoryFixture fix;
TreeSnapshotRecord base;
base.snapshotId = "snapshot-base";
base.rootTreeHash = "root-base";
base.createdTime = 100;
base.fileCount = 1;
base.metadata["directory_path"] = "/repo";
base.metadata["snapshot_label"] = "base";
base.metadata["git_commit"] = "abc123";
base.metadata["git_branch"] = "main";
base.metadata["git_remote"] = "origin";
TreeSnapshotRecord target = base;
target.snapshotId = "snapshot-target";
target.rootTreeHash = "root-target";
target.createdTime = 200;
target.fileCount = 2;
target.metadata["snapshot_label"] = "target";
target.metadata["git_commit"] = "def456";
REQUIRE((fix.repository_->upsertTreeSnapshot(base).has_value()));
REQUIRE((fix.repository_->upsertTreeSnapshot(target).has_value()));
auto snapshots = fix.repository_->listTreeSnapshots(10);
REQUIRE((snapshots.has_value()));
REQUIRE((snapshots.value().size() >= 2));
CHECK((snapshots.value().front().snapshotId == "snapshot-target"));
CHECK((snapshots.value().front().metadata.at("snapshot_label") == "target"));
CHECK((snapshots.value().front().metadata.at("git_commit") == "def456"));
CHECK((snapshots.value().front().fileCount == 2));
auto fetchedTarget = fix.repository_->getTreeSnapshot(target.snapshotId);
if (!fetchedTarget.has_value()) {
INFO("getTreeSnapshot error: " << fetchedTarget.error().message);
}
REQUIRE((fetchedTarget.has_value()));
REQUIRE((fetchedTarget.value().has_value()));
CHECK((fetchedTarget.value()->snapshotId == target.snapshotId));
CHECK((fetchedTarget.value()->rootTreeHash == target.rootTreeHash));
CHECK((fetchedTarget.value()->metadata.at("directory_path") == "/repo"));
CHECK((fetchedTarget.value()->metadata.at("snapshot_label") == "target"));
CHECK((fetchedTarget.value()->metadata.at("git_commit") == "def456"));
CHECK((fetchedTarget.value()->fileCount == 2));
auto missingSnapshot = fix.repository_->getTreeSnapshot("missing-snapshot");
REQUIRE((missingSnapshot.has_value()));
CHECK_FALSE((missingSnapshot.value().has_value()));
TreeDiffDescriptor descriptor;
descriptor.baseSnapshotId = base.snapshotId;
descriptor.targetSnapshotId = target.snapshotId;
descriptor.computedAt = 300;
descriptor.status = "pending";
auto diffId = fix.repository_->beginTreeDiff(descriptor);
REQUIRE((diffId.has_value()));
REQUIRE((diffId.value() > 0));
TreeChangeRecord added;
added.type = TreeChangeType::Added;
added.newPath = "/repo/src/new.cpp";
added.newHash = "new-hash";
added.mode = 0644;
added.contentDeltaHash = "delta-hash";
TreeChangeRecord moved;
moved.type = TreeChangeType::Moved;
moved.oldPath = "/repo/src/old.cpp";
moved.newPath = "/repo/include/old.hpp";
moved.oldHash = "old-hash";
moved.newHash = "old-hash";
TreeChangeRecord deletedDirectory;
deletedDirectory.type = TreeChangeType::Deleted;
deletedDirectory.oldPath = "/repo/tmp";
deletedDirectory.isDirectory = true;
REQUIRE(fix.repository_->appendTreeChanges(diffId.value(), {added, moved, deletedDirectory})
.has_value());
TreeDiffQuery allChanges;
allChanges.baseSnapshotId = base.snapshotId;
allChanges.targetSnapshotId = target.snapshotId;
allChanges.limit = 10;
auto changes = fix.repository_->listTreeChanges(allChanges);
REQUIRE((changes.has_value()));
REQUIRE((changes.value().size() == 3));
CHECK((changes.value()[0].type == TreeChangeType::Added));
CHECK((changes.value()[1].type == TreeChangeType::Moved));
CHECK((changes.value()[2].type == TreeChangeType::Deleted));
TreeDiffQuery addedUnderSrc;
addedUnderSrc.baseSnapshotId = base.snapshotId;
addedUnderSrc.targetSnapshotId = target.snapshotId;
addedUnderSrc.pathPrefix = "/repo/src";
addedUnderSrc.typeFilter = TreeChangeType::Added;
auto filtered = fix.repository_->listTreeChanges(addedUnderSrc);
REQUIRE((filtered.has_value()));
REQUIRE((filtered.value().size() == 1));
CHECK((filtered.value().front().newPath == added.newPath));
REQUIRE((filtered.value().front().mode.has_value()));
CHECK((filtered.value().front().mode.value() == 0644));
REQUIRE((filtered.value().front().contentDeltaHash.has_value()));
CHECK((filtered.value().front().contentDeltaHash.value() == "delta-hash"));
REQUIRE(fix.repository_->finalizeTreeDiff(diffId.value(), changes.value().size(), "complete")
.has_value());
auto verifyStatus = fix.pool_->withConnection([&](Database& db) -> Result<void> {
auto stmtResult = db.prepare(R"(
SELECT files_added, files_deleted, files_modified, files_renamed, status
FROM tree_diffs WHERE diff_id = ?
)");
REQUIRE((stmtResult.has_value()));
auto& stmt = stmtResult.value();
REQUIRE((stmt.bind(1, diffId.value()).has_value()));
auto step = stmt.step();
REQUIRE((step.has_value()));
REQUIRE((step.value()));
CHECK((stmt.getInt64(0) == 1));
CHECK((stmt.getInt64(1) == 0));
CHECK((stmt.getInt64(2) == 0));
CHECK((stmt.getInt64(3) == 1));
CHECK((stmt.getString(4) == "complete"));
return Result<void>();
});
REQUIRE((verifyStatus.has_value()));
}
// --- batchInsertContentAndIndex counter accuracy tests ---
TEST_CASE("batchInsertContentAndIndex: fresh documents increment extracted and indexed counters",
"[unit][metadata-repo][batch-content][counters]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
// Insert 3 fresh documents (not yet extracted/indexed)
std::vector<int64_t> docIds;
for (int i = 0; i < 3; ++i) {
DocumentInfo doc;
doc.sha256Hash = "counter_hash_fresh_" + std::to_string(i);
doc.fileName = "counter_fresh_" + std::to_string(i) + ".txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
// Leave contentExtracted=false, extractionStatus=Pending (defaults)
doc.contentExtracted = false;
doc.extractionStatus = ExtractionStatus::Pending;
auto result = fix.repository_->insertDocument(doc);
REQUIRE((result.has_value()));
docIds.push_back(result.value());
}
auto extractedBefore = fix.repository_->getCachedExtractedCount();
auto indexedBefore = fix.repository_->getCachedIndexedCount();
// Batch insert content for all 3
std::vector<BatchContentEntry> entries;
for (int i = 0; i < 3; ++i) {
BatchContentEntry entry;
entry.documentId = docIds[static_cast<size_t>(i)];
entry.title = "Title " + std::to_string(i);
entry.contentText = "Content text " + std::to_string(i);
entry.mimeType = "text/plain";
entry.extractionMethod = "test";
entry.language = "en";
entry.priorStateKnown = true;
entry.priorContentExtracted = false;
entry.priorExtractionStatus = ExtractionStatus::Pending;
entries.push_back(entry);
}
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
CHECK((fix.repository_->getCachedExtractedCount() == extractedBefore + 3));
CHECK((fix.repository_->getCachedIndexedCount() == indexedBefore + 3));
for (auto docId : docIds) {
auto docResult = fix.repository_->getDocument(docId);
REQUIRE((docResult.has_value()));
REQUIRE((docResult.value().has_value()));
CHECK((docResult.value()->repairStatus == RepairStatus::Completed));
CHECK((docResult.value()->repairAttempts == 1));
}
}
TEST_CASE("batchInsertContentAndIndex persists extracted title metadata in the content transaction",
"[unit][metadata-repo][batch-content][title]") {
MetadataRepositoryFixture fix;
auto inserted = fix.repository_->insertDocument(
makeDocumentWithPath("/tmp/content-title.txt", "content_title_hash", "text/plain"));
REQUIRE((inserted.has_value()));
auto entry = makeBatchContentEntry(inserted.value(), "FTS fallback title", "Content text");
entry.metadataTitle = "Extracted document title";
REQUIRE((fix.repository_->batchInsertContentAndIndex({entry}).has_value()));
auto title = fix.repository_->getMetadata(inserted.value(), "title");
REQUIRE((title.has_value()));
REQUIRE((title.value().has_value()));
CHECK((title.value()->value == "Extracted document title"));
}
TEST_CASE(
"batchInsertContentAndIndex: duplicate document entries preserve per-entry repair attempts",
"[unit][metadata-repo][batch-content][status-update][duplicates]") {
MetadataRepositoryFixture fix;
DocumentInfo doc;
doc.sha256Hash = "counter_hash_duplicate_status";
doc.fileName = "duplicate_status.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
const auto docId = insertResult.value();
BatchContentEntry first = makeBatchContentEntry(docId, "First title", "First content");
BatchContentEntry second = makeBatchContentEntry(docId, "Second title", "Second content");
auto batchResult = fix.repository_->batchInsertContentAndIndex({first, second});
REQUIRE((batchResult.has_value()));
auto docResult = fix.repository_->getDocument(docId);
REQUIRE((docResult.has_value()));
REQUIRE((docResult.value().has_value()));
CHECK((docResult.value()->repairStatus == RepairStatus::Completed));
CHECK((docResult.value()->repairAttempts == 2));
auto contentResult = fix.repository_->getContent(docId);
REQUIRE((contentResult.has_value()));
REQUIRE((contentResult.value().has_value()));
CHECK((contentResult.value()->contentText == "Second content"));
}
TEST_CASE("batchInsertContentAndIndex: skips stale missing document ids",
"[unit][metadata-repo][batch-content][counters][stale]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
const auto docsBefore = fix.repository_->getCachedDocumentCount();
const auto extractedBefore = fix.repository_->getCachedExtractedCount();
const auto indexedBefore = fix.repository_->getCachedIndexedCount();
BatchContentEntry stale;
stale.documentId = 999999;
stale.title = "stale title";
stale.contentText = "stale content";
stale.mimeType = "text/plain";
stale.extractionMethod = "test";
stale.language = "en";
auto batchResult = fix.repository_->batchInsertContentAndIndex({stale});
REQUIRE((batchResult.has_value()));
CHECK((fix.repository_->getCachedDocumentCount() == docsBefore));
CHECK((fix.repository_->getCachedExtractedCount() == extractedBefore));
CHECK((fix.repository_->getCachedIndexedCount() == indexedBefore));
auto exactIndexed = fix.repository_->getIndexedDocumentCount();
REQUIRE((exactIndexed.has_value()));
CHECK((static_cast<uint64_t>(exactIndexed.value()) == indexedBefore));
}
TEST_CASE("batchInsertContentAndIndex: updates warmed FTS indexed ID cache",
"[unit][metadata-repo][batch-content][fts-cache]") {
MetadataRepositoryFixture fix;
std::vector<int64_t> docIds;
for (int i = 0; i < 3; ++i) {
DocumentInfo doc;
doc.sha256Hash = "fts_cache_batch_hash_" + std::to_string(i);
doc.fileName = "fts_cache_batch_" + std::to_string(i) + ".txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto result = fix.repository_->insertDocument(doc);
REQUIRE((result.has_value()));
docIds.push_back(result.value());
}
auto initialIds = fix.repository_->getAllFts5IndexedDocumentIds();
REQUIRE((initialIds.has_value()));
REQUIRE((initialIds.value().empty()));
std::vector<BatchContentEntry> entries;
for (int i = 0; i < 3; ++i) {
BatchContentEntry entry;
entry.documentId = docIds[static_cast<size_t>(i)];
entry.title = "FTS Cache Title " + std::to_string(i);
entry.contentText = "FTS cache content " + std::to_string(i);
entry.mimeType = "text/plain";
entry.extractionMethod = "test";
entry.language = "en";
entries.push_back(entry);
}
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
auto indexedIds = fix.repository_->getAllFts5IndexedDocumentIds();
REQUIRE((indexedIds.has_value()));
auto actual = indexedIds.value();
std::sort(actual.begin(), actual.end());
std::sort(docIds.begin(), docIds.end());
CHECK((actual == docIds));
}
TEST_CASE("batchInsertContentAndIndex: already-extracted documents don't double-count",
"[unit][metadata-repo][batch-content][counters]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
DocumentInfo doc;
doc.sha256Hash = "counter_hash_already";
doc.fileName = "counter_already.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
// First batch insert — transitions to extracted+indexed
std::vector<BatchContentEntry> entries = {makeBatchContentEntry(docId, "Title", "Content")};
auto batch1 = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batch1.has_value()));
auto extractedAfterFirst = fix.repository_->getCachedExtractedCount();
auto indexedAfterFirst = fix.repository_->getCachedIndexedCount();
// Re-insert same document — counters should not change
auto batch2 = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batch2.has_value()));
CHECK((fix.repository_->getCachedExtractedCount() == extractedAfterFirst));
CHECK((fix.repository_->getCachedIndexedCount() == indexedAfterFirst));
}
TEST_CASE("batchInsertContentAndIndex: stale extraction hints don't double-count",
"[unit][metadata-repo][batch-content][counters]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
auto inserted = fix.repository_->insertDocument(makeDocumentWithPath(
"/tmp/stale-extraction-hint.txt", "stale_extraction_hint_hash", "text/plain"));
REQUIRE((inserted.has_value()));
auto staleEntry = makeBatchContentEntry(inserted.value(), "Title", "First content");
staleEntry.priorStateKnown = true;
staleEntry.priorContentExtracted = false;
REQUIRE((fix.repository_->batchInsertContentAndIndex({staleEntry}).has_value()));
const auto extractedAfterFirst = fix.repository_->getCachedExtractedCount();
const auto indexedAfterFirst = fix.repository_->getCachedIndexedCount();
// Concurrent post-ingest tasks for duplicate content can carry the same pre-commit hint.
// The repository must reconcile each transaction against the database state it serializes
// behind, rather than trusting a hint captured before an earlier transaction committed.
staleEntry.contentText = "Second content";
REQUIRE((fix.repository_->batchInsertContentAndIndex({staleEntry}).has_value()));
CHECK((fix.repository_->getCachedExtractedCount() == extractedAfterFirst));
CHECK((fix.repository_->getCachedIndexedCount() == indexedAfterFirst));
}
TEST_CASE("batchInsertContentAndIndex: mixed fresh and already-extracted batch",
"[unit][metadata-repo][batch-content][counters]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
// Insert 2 documents
DocumentInfo doc1;
doc1.sha256Hash = "counter_mixed_1";
doc1.fileName = "mixed_1.txt";
doc1.fileSize = 100;
doc1.mimeType = "text/plain";
doc1.createdTime = now;
doc1.modifiedTime = now;
auto id1 = fix.repository_->insertDocument(doc1);
REQUIRE((id1.has_value()));
DocumentInfo doc2;
doc2.sha256Hash = "counter_mixed_2";
doc2.fileName = "mixed_2.txt";
doc2.fileSize = 200;
doc2.mimeType = "text/plain";
doc2.createdTime = now;
doc2.modifiedTime = now;
auto id2 = fix.repository_->insertDocument(doc2);
REQUIRE((id2.has_value()));
// Pre-extract doc1 via batchInsertContentAndIndex so it has extraction_status='Success'
std::vector<BatchContentEntry> preEntries = {
makeBatchContentEntry(id1.value(), "Title 1", "Content 1")};
auto preResult = fix.repository_->batchInsertContentAndIndex(preEntries);
REQUIRE((preResult.has_value()));
auto extractedBefore = fix.repository_->getCachedExtractedCount();
auto indexedBefore = fix.repository_->getCachedIndexedCount();
// Now batch both: doc1 (already extracted) + doc2 (fresh)
std::vector<BatchContentEntry> entries = {
makeBatchContentEntry(id1.value(), "Title 1 updated", "Content 1 updated"),
makeBatchContentEntry(id2.value(), "Title 2", "Content 2")};
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
// Only doc2 (fresh) should increment counters
CHECK((fix.repository_->getCachedExtractedCount() == extractedBefore + 1));
CHECK((fix.repository_->getCachedIndexedCount() == indexedBefore + 1));
}
TEST_CASE(
"batchInsertContentAndIndex: priorStateKnown must not treat extraction success as FTS indexed",
"[unit][metadata-repo][batch-content][counters][fts]") {
MetadataRepositoryFixture fix;
DocumentInfo doc;
doc.sha256Hash = "counter_hash_missing_fts";
doc.fileName = "missing_fts.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
BatchContentEntry initialEntry;
initialEntry.documentId = docId;
initialEntry.title = "Title";
initialEntry.contentText = "Content";
initialEntry.mimeType = "text/plain";
initialEntry.extractionMethod = "test";
initialEntry.language = "en";
REQUIRE((fix.repository_->batchInsertContentAndIndex({initialEntry}).has_value()));
REQUIRE(fix.pool_
->withConnection([&](Database& db) -> Result<void> {
auto stmtResult = db.prepare("DELETE FROM documents_fts WHERE rowid = ?");
REQUIRE((stmtResult.has_value()));
auto& stmt = stmtResult.value();
REQUIRE((stmt.bind(1, docId).has_value()));
REQUIRE((stmt.execute().has_value()));
return {};
})
.has_value());
fix.repository_->initializeCounters();
auto indexedBefore = fix.repository_->getCachedIndexedCount();
REQUIRE((indexedBefore == 0));
BatchContentEntry repairEntry;
repairEntry.documentId = docId;
repairEntry.title = "Title";
repairEntry.contentText = "Content repaired";
repairEntry.mimeType = "text/plain";
repairEntry.extractionMethod = "test";
repairEntry.language = "en";
repairEntry.priorStateKnown = true;
repairEntry.priorContentExtracted = true;
repairEntry.priorExtractionStatus = ExtractionStatus::Success;
auto batchResult = fix.repository_->batchInsertContentAndIndex({repairEntry});
REQUIRE((batchResult.has_value()));
auto indexedIds = fix.repository_->getAllFts5IndexedDocumentIds();
REQUIRE((indexedIds.has_value()));
REQUIRE(std::find(indexedIds.value().begin(), indexedIds.value().end(), docId) !=
indexedIds.value().end());
CHECK((fix.repository_->getCachedIndexedCount() == indexedBefore + 1));
}
TEST_CASE("batchInsertContentAndIndex: mixed repaired and stale entries stay accurate",
"[unit][metadata-repo][batch-content][counters][fts]") {
MetadataRepositoryFixture fix;
DocumentInfo doc;
doc.sha256Hash = "counter_hash_mixed_repair_stale";
doc.fileName = "mixed_repair_stale.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
BatchContentEntry initialEntry;
initialEntry.documentId = docId;
initialEntry.title = "Title";
initialEntry.contentText = "Content";
initialEntry.mimeType = "text/plain";
initialEntry.extractionMethod = "test";
initialEntry.language = "en";
REQUIRE((fix.repository_->batchInsertContentAndIndex({initialEntry}).has_value()));
REQUIRE(fix.pool_
->withConnection([&](Database& db) -> Result<void> {
auto stmtResult = db.prepare("DELETE FROM documents_fts WHERE rowid = ?");
REQUIRE((stmtResult.has_value()));
auto& stmt = stmtResult.value();
REQUIRE((stmt.bind(1, docId).has_value()));
REQUIRE((stmt.execute().has_value()));
return {};
})
.has_value());
fix.repository_->initializeCounters();
auto indexedBefore = fix.repository_->getCachedIndexedCount();
REQUIRE((indexedBefore == 0));
BatchContentEntry repairEntry;
repairEntry.documentId = docId;
repairEntry.title = "Title repaired";
repairEntry.contentText = "Content repaired";
repairEntry.mimeType = "text/plain";
repairEntry.extractionMethod = "test";
repairEntry.language = "en";
repairEntry.priorStateKnown = true;
repairEntry.priorContentExtracted = true;
repairEntry.priorExtractionStatus = ExtractionStatus::Success;
BatchContentEntry staleEntry = repairEntry;
staleEntry.documentId = docId + 999999;
staleEntry.title = "Stale";
staleEntry.contentText = "Stale content";
auto batchResult = fix.repository_->batchInsertContentAndIndex({repairEntry, staleEntry});
REQUIRE((batchResult.has_value()));
auto indexedIds = fix.repository_->getAllFts5IndexedDocumentIds();
REQUIRE((indexedIds.has_value()));
REQUIRE(std::find(indexedIds.value().begin(), indexedIds.value().end(), docId) !=
indexedIds.value().end());
CHECK((fix.repository_->getCachedIndexedCount() == indexedBefore + 1));
}
TEST_CASE("batchInsertContentAndIndex: clears extraction_error",
"[unit][metadata-repo][batch-content][counters]") {
MetadataRepositoryFixture fix;
fix.repository_->initializeCounters();
// Insert a document with extraction_status=Success but an error set
DocumentInfo doc;
doc.sha256Hash = "counter_hash_error_clear";
doc.fileName = "error_clear.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
doc.contentExtracted = true;
doc.extractionStatus = ExtractionStatus::Success;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
auto docId = insertResult.value();
// Manually set an extraction error via raw SQL
fix.pool_->withConnection([&](Database& db) -> Result<void> {
auto stmtResult =
db.prepare("UPDATE documents SET extraction_error = 'stale error' WHERE id = ?");
REQUIRE((stmtResult.has_value()));
auto& stmt = stmtResult.value();
REQUIRE((stmt.bind(1, docId).has_value()));
REQUIRE((stmt.execute().has_value()));
return {};
});
// Batch insert should clear the error
std::vector<BatchContentEntry> entries = {makeBatchContentEntry(docId, "Title", "Content")};
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
// Verify error is cleared
fix.pool_->withConnection([&](Database& db) -> Result<void> {
auto stmtResult = db.prepare("SELECT extraction_error FROM documents WHERE id = ?");
REQUIRE((stmtResult.has_value()));
auto& stmt = stmtResult.value();
REQUIRE((stmt.bind(1, docId).has_value()));
auto hasRow = stmt.step();
REQUIRE((hasRow.has_value()));
REQUIRE((hasRow.value()));
CHECK((stmt.isNull(0)));
return {};
});
}
TEST_CASE("batchInsertContentAndIndex: does not amplify lock retries",
"[unit][metadata-repo][batch-content][contention]") {
const auto dbPath = tempDbPath("metadata_repo_batch_content_lock_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc = makeDocumentWithPath("/tmp/batch-content-lock.txt", "batch-content-lock-hash");
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
BatchContentEntry entry;
entry.documentId = insert.value();
entry.title = "Title";
entry.contentText = "Locked content write";
entry.mimeType = "text/plain";
entry.extractionMethod = "test";
entry.language = "en";
entry.priorStateKnown = true;
entry.priorContentExtracted = false;
entry.priorExtractionStatus = ExtractionStatus::Pending;
const auto start = std::chrono::steady_clock::now();
auto result = repository->batchInsertContentAndIndex({entry});
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((result.error().message.find("locked") != std::string::npos));
CHECK((elapsed < std::chrono::milliseconds(3000)));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
// --- batchGetDocumentsWithContentPreview tests ---
TEST_CASE("batchGetDocumentsWithContentPreview: documents with content",
"[metadata-repo][batch-docs-preview]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
// Insert 3 documents with content
std::vector<int64_t> docIds;
std::vector<std::string> hashes;
for (int i = 0; i < 3; ++i) {
DocumentInfo doc;
doc.sha256Hash = "preview_hash_" + std::to_string(i);
doc.fileName = "preview_doc_" + std::to_string(i) + ".txt";
doc.filePath = "/test/preview_doc_" + std::to_string(i) + ".txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = now;
doc.modifiedTime = now;
auto result = fix.repository_->insertDocument(doc);
REQUIRE((result.has_value()));
docIds.push_back(result.value());
hashes.push_back(doc.sha256Hash);
}
// Insert content for each document
std::vector<BatchContentEntry> entries;
for (int i = 0; i < 3; ++i) {
entries.push_back(makeBatchContentEntry(docIds[static_cast<size_t>(i)],
"Title " + std::to_string(i),
"Content text for document " + std::to_string(i)));
}
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
// Fetch with combined method
auto result = fix.repository_->batchGetDocumentsWithContentPreview(hashes, 500);
REQUIRE((result.has_value()));
auto& map = result.value();
CHECK((map.size() == 3));
for (int i = 0; i < 3; ++i) {
std::string hash = "preview_hash_" + std::to_string(i);
REQUIRE((map.count(hash) == 1));
auto& [info, preview] = map.at(hash);
CHECK((info.id == docIds[static_cast<size_t>(i)]));
CHECK((info.sha256Hash == hash));
CHECK_FALSE(preview.empty());
CHECK(
(preview.find("Content text for document " + std::to_string(i)) != std::string::npos));
}
}
TEST_CASE("batchGetDocumentsWithContentPreview: documents without content",
"[metadata-repo][batch-docs-preview]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
DocumentInfo doc;
doc.sha256Hash = "no_content_hash";
doc.fileName = "no_content_doc.txt";
doc.filePath = "/test/no_content_doc.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = now;
doc.modifiedTime = now;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
std::vector<std::string> hashes = {"no_content_hash"};
auto result = fix.repository_->batchGetDocumentsWithContentPreview(hashes, 500);
REQUIRE((result.has_value()));
auto& map = result.value();
REQUIRE((map.size() == 1));
auto& [info, preview] = map.at("no_content_hash");
CHECK((info.id == insertResult.value()));
CHECK((preview.empty()));
}
TEST_CASE("batchGetDocumentsWithContentPreview: mixed with and without content",
"[metadata-repo][batch-docs-preview]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
// Doc with content
DocumentInfo doc1;
doc1.sha256Hash = "mixed_hash_with";
doc1.fileName = "mixed_with.txt";
doc1.filePath = "/test/mixed_with.txt";
doc1.fileSize = 100;
doc1.mimeType = "text/plain";
doc1.createdTime = now;
doc1.modifiedTime = now;
auto id1 = fix.repository_->insertDocument(doc1);
REQUIRE((id1.has_value()));
std::vector<BatchContentEntry> entries = {
makeBatchContentEntry(id1.value(), "Title", "Has content")};
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
// Doc without content
DocumentInfo doc2;
doc2.sha256Hash = "mixed_hash_without";
doc2.fileName = "mixed_without.txt";
doc2.filePath = "/test/mixed_without.txt";
doc2.fileSize = 100;
doc2.mimeType = "text/plain";
doc2.createdTime = now;
doc2.modifiedTime = now;
auto id2 = fix.repository_->insertDocument(doc2);
REQUIRE((id2.has_value()));
std::vector<std::string> hashes = {"mixed_hash_with", "mixed_hash_without"};
auto result = fix.repository_->batchGetDocumentsWithContentPreview(hashes, 500);
REQUIRE((result.has_value()));
auto& map = result.value();
CHECK((map.size() == 2));
CHECK_FALSE(map.at("mixed_hash_with").second.empty());
CHECK((map.at("mixed_hash_without").second.empty()));
}
TEST_CASE("batchGetDocumentsWithContentPreview: empty hash list",
"[metadata-repo][batch-docs-preview]") {
MetadataRepositoryFixture fix;
std::vector<std::string> hashes;
auto result = fix.repository_->batchGetDocumentsWithContentPreview(hashes, 500);
REQUIRE((result.has_value()));
CHECK((result.value().empty()));
}
TEST_CASE("batchGetDocumentsWithContentPreview: preview truncation",
"[metadata-repo][batch-docs-preview]") {
MetadataRepositoryFixture fix;
auto now = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
DocumentInfo doc;
doc.sha256Hash = "truncate_hash";
doc.fileName = "truncate_doc.txt";
doc.filePath = "/test/truncate_doc.txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime = now;
doc.modifiedTime = now;
auto insertResult = fix.repository_->insertDocument(doc);
REQUIRE((insertResult.has_value()));
// Insert large content (10KB)
std::string largeContent(10000, 'x');
std::vector<BatchContentEntry> entries = {
makeBatchContentEntry(insertResult.value(), "Title", largeContent)};
auto batchResult = fix.repository_->batchInsertContentAndIndex(entries);
REQUIRE((batchResult.has_value()));
std::vector<std::string> hashes = {"truncate_hash"};
auto result = fix.repository_->batchGetDocumentsWithContentPreview(hashes, 200);
REQUIRE((result.has_value()));
auto& [info, preview] = result.value().at("truncate_hash");
CHECK((preview.size() <= 200));
CHECK_FALSE(preview.empty());
}
TEST_CASE("MetadataRepository: setMetadataBatch preserves typed values and latest write wins",
"[unit][metadata][repository][batch][metadata-value]") {
MetadataRepositoryFixture fix;
auto doc = makeDocumentWithPath("/tmp/typed-metadata.txt", "typed-metadata-hash");
auto insert = fix.repository_->insertDocument(doc);
REQUIRE((insert.has_value()));
const int64_t docId = insert.value();
const std::vector<uint8_t> blob = {0x00, 0x01, 0x41, 0xFE, 0xFF};
std::vector<std::tuple<int64_t, std::string, MetadataValue>> entries = {
{docId, "author", MetadataValue("old")},
{docId, "author", MetadataValue("new")},
{docId, "revision", MetadataValue(int64_t{42})},
{docId, "score", MetadataValue(0.875)},
{docId, "published", MetadataValue(true)},
{docId, "payload", MetadataValue::fromBlob(blob)},
};
auto setBatch = fix.repository_->setMetadataBatch(entries);
REQUIRE((setBatch.has_value()));
auto all = fix.repository_->getAllMetadata(docId);
REQUIRE((all.has_value()));
REQUIRE((all.value().size() == 5));
CHECK((all.value().at("author").type == MetadataValueType::String));
CHECK((all.value().at("author").asString() == "new"));
CHECK((all.value().at("revision").type == MetadataValueType::Integer));
CHECK((all.value().at("revision").asInteger() == 42));
CHECK((all.value().at("score").type == MetadataValueType::Real));
CHECK_THAT(all.value().at("score").asReal(), Catch::Matchers::WithinRel(0.875, 0.0001));
CHECK((all.value().at("published").type == MetadataValueType::Boolean));
CHECK((all.value().at("published").asBoolean()));
CHECK((all.value().at("payload").type == MetadataValueType::Blob));
auto payload = all.value().at("payload").asVariant();
REQUIRE((std::holds_alternative<std::vector<uint8_t>>(payload)));
CHECK((std::get<std::vector<uint8_t>>(payload) == blob));
std::vector<int64_t> ids{docId, 999999};
auto batch = fix.repository_->getMetadataForDocuments(ids);
REQUIRE((batch.has_value()));
REQUIRE((batch.value().contains(docId)));
CHECK((batch.value().at(docId).at("author").asString() == "new"));
CHECK_FALSE(batch.value().contains(999999));
}
TEST_CASE("MetadataRepository: setMetadataBatch does not amplify lock retries",
"[unit][metadata][repository][batch][metadata][contention]") {
const auto dbPath = tempDbPath("metadata_repo_set_metadata_batch_lock_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc = makeDocumentWithPath("/tmp/metadata-lock.txt", "metadata-lock-hash");
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
const auto start = std::chrono::steady_clock::now();
auto result = repository->setMetadataBatch(
{{insert.value(), "title", MetadataValue("locked metadata write")}});
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((result.error().message.find("locked") != std::string::npos));
CHECK((elapsed < std::chrono::milliseconds(3000)));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: feedback event write does not wait for a busy pool",
"[unit][metadata][repository][feedback][contention]") {
const auto dbPath = tempDbPath("metadata_repo_feedback_pool_busy_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto heldConnResult = repoPool->acquire();
REQUIRE((heldConnResult.has_value()));
auto heldConn = std::move(heldConnResult).value();
FeedbackEvent event;
event.eventId = "feedback-busy-pool-event";
event.traceId = "feedback-busy-pool-trace";
event.createdAt = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
event.source = "test";
event.eventType = "retrieval_served";
event.payloadJson = "{\"served\":1}";
const auto start = std::chrono::steady_clock::now();
auto result = repository->insertFeedbackEvent(event);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE_FALSE((result.has_value()));
CHECK((elapsed < std::chrono::milliseconds(250)));
CHECK((result.error().code == ErrorCode::Timeout ||
result.error().code == ErrorCode::DatabaseError ||
result.error().message.find("Timeout acquiring connection") != std::string::npos));
heldConn.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataWriteFacade: fallback path batches writes until flush",
"[unit][daemon][metadata-write-facade][fallback]") {
const auto dbPath = tempDbPath("metadata_write_facade_fallback_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
auto doc = makeDocumentWithPath("/tmp/facade-fallback.txt", "facade-fallback-hash");
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
const auto docId = insert.value();
REQUIRE((repository->updateDocumentExtractionStatus(docId, true, ExtractionStatus::Success)
.has_value()));
REQUIRE(
(repository->batchUpdateDocumentRepairStatuses({doc.sha256Hash}, RepairStatus::Completed)
.has_value()));
yams::daemon::MetadataWriteFacade facade(nullptr, repository.get());
facade.setMetadata(docId, "title", MetadataValue(std::string("title-1")));
facade.setMetadata(docId, "title", MetadataValue(std::string("title-2")));
facade.updateExtractionStatus(docId, false, ExtractionStatus::Pending,
"reset by fallback facade");
facade.updateRepairStatus({doc.sha256Hash}, RepairStatus::Processing);
facade.updateRepairStatus({doc.sha256Hash}, RepairStatus::Pending);
auto metadataBeforeFlush = repository->getMetadata(docId, "title");
REQUIRE((metadataBeforeFlush.has_value()));
CHECK_FALSE((metadataBeforeFlush.value().has_value()));
auto docBeforeFlush = repository->getDocument(docId);
REQUIRE((docBeforeFlush.has_value()));
REQUIRE((docBeforeFlush.value().has_value()));
CHECK((docBeforeFlush.value()->extractionStatus == ExtractionStatus::Success));
CHECK((docBeforeFlush.value()->repairStatus == RepairStatus::Completed));
facade.flush();
auto metadataAfterFlush = repository->getMetadata(docId, "title");
REQUIRE((metadataAfterFlush.has_value()));
REQUIRE((metadataAfterFlush.value().has_value()));
CHECK((metadataAfterFlush.value()->asString() == "title-2"));
auto docAfterFlush = repository->getDocument(docId);
REQUIRE((docAfterFlush.has_value()));
REQUIRE((docAfterFlush.value().has_value()));
CHECK((docAfterFlush.value()->extractionStatus == ExtractionStatus::Pending));
CHECK((docAfterFlush.value()->repairStatus == RepairStatus::Pending));
CHECK_FALSE((docAfterFlush.value()->contentExtracted));
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataWriteFacade: fallback flush retains pending writes after a transient lock",
"[unit][daemon][metadata-write-facade][fallback][contention]") {
const auto dbPath = tempDbPath("metadata_write_facade_retry_");
ConnectionPoolConfig repoCfg;
repoCfg.minConnections = 1;
repoCfg.maxConnections = 1;
repoCfg.busyTimeout = std::chrono::milliseconds(10);
auto repoPool = std::make_unique<ConnectionPool>(dbPath.string(), repoCfg);
REQUIRE((repoPool->initialize().has_value()));
auto repository = std::make_unique<MetadataRepository>(
*repoPool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
ConnectionPoolConfig lockerCfg;
lockerCfg.minConnections = 1;
lockerCfg.maxConnections = 1;
lockerCfg.busyTimeout = std::chrono::milliseconds(10);
auto lockerPool = std::make_unique<ConnectionPool>(dbPath.string(), lockerCfg);
REQUIRE((lockerPool->initialize().has_value()));
auto doc = makeDocumentWithPath("/tmp/facade-fallback-retry.txt", "facade-fallback-retry-hash");
auto insert = repository->insertDocument(doc);
REQUIRE((insert.has_value()));
const auto docId = insert.value();
REQUIRE((repository->updateDocumentExtractionStatus(docId, true, ExtractionStatus::Success)
.has_value()));
REQUIRE(
(repository->batchUpdateDocumentRepairStatuses({doc.sha256Hash}, RepairStatus::Completed)
.has_value()));
yams::daemon::MetadataWriteFacade facade(nullptr, repository.get());
facade.setMetadata(docId, "title", MetadataValue(std::string("title-after-retry")));
facade.updateExtractionStatus(docId, false, ExtractionStatus::Pending,
"retry after lock clears");
facade.updateRepairStatus({doc.sha256Hash}, RepairStatus::Pending);
auto heldConn = lockerPool->acquire();
REQUIRE((heldConn.has_value()));
REQUIRE(((*heldConn.value())->execute("BEGIN IMMEDIATE").has_value()));
facade.flush();
auto metadataDuringLock = repository->getMetadata(docId, "title");
REQUIRE((metadataDuringLock.has_value()));
CHECK_FALSE((metadataDuringLock.value().has_value()));
auto docDuringLock = repository->getDocument(docId);
REQUIRE((docDuringLock.has_value()));
REQUIRE((docDuringLock.value().has_value()));
CHECK((docDuringLock.value()->extractionStatus == ExtractionStatus::Success));
CHECK((docDuringLock.value()->repairStatus == RepairStatus::Completed));
CHECK((docDuringLock.value()->contentExtracted));
REQUIRE(((*heldConn.value())->execute("ROLLBACK").has_value()));
facade.flush();
auto metadataAfterRetry = repository->getMetadata(docId, "title");
REQUIRE((metadataAfterRetry.has_value()));
REQUIRE((metadataAfterRetry.value().has_value()));
CHECK((metadataAfterRetry.value()->asString() == "title-after-retry"));
auto docAfterRetry = repository->getDocument(docId);
REQUIRE((docAfterRetry.has_value()));
REQUIRE((docAfterRetry.value().has_value()));
CHECK((docAfterRetry.value()->extractionStatus == ExtractionStatus::Pending));
CHECK((docAfterRetry.value()->repairStatus == RepairStatus::Pending));
CHECK_FALSE((docAfterRetry.value()->contentExtracted));
lockerPool->shutdown();
lockerPool.reset();
repository.reset();
repoPool->shutdown();
repoPool.reset();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataRepository: getMetadataForDocuments chunks large batches",
"[unit][metadata][repository][batch][metadata]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/chunk-a.txt", "chunk-metadata-hash-a");
auto docB = makeDocumentWithPath("/tmp/chunk-b.txt", "chunk-metadata-hash-b");
auto docAInsert = fix.repository_->insertDocument(docA);
auto docBInsert = fix.repository_->insertDocument(docB);
REQUIRE((docAInsert.has_value()));
REQUIRE((docBInsert.has_value()));
const int64_t docAId = docAInsert.value();
const int64_t docBId = docBInsert.value();
REQUIRE((fix.repository_->setMetadata(docAId, "author", MetadataValue("alpha")).has_value()));
REQUIRE(
(fix.repository_->setMetadata(docBId, "revision", MetadataValue(int64_t{7})).has_value()));
constexpr std::size_t kLargeBatchSize = 33000;
std::vector<int64_t> ids(kLargeBatchSize, 999999);
ids.front() = docAId;
ids[kLargeBatchSize / 2] = docBId;
ids.back() = docAId;
auto batch = fix.repository_->getMetadataForDocuments(ids);
REQUIRE((batch.has_value()));
REQUIRE((batch.value().size() == 2));
REQUIRE((batch.value().contains(docAId)));
REQUIRE((batch.value().contains(docBId)));
CHECK((batch.value().at(docAId).at("author").asString() == "alpha"));
CHECK((batch.value().at(docBId).at("revision").asInteger() == 7));
CHECK_FALSE(batch.value().contains(999999));
}
TEST_CASE("MetadataRepository: batch document mutations preserve observable behavior",
"[unit][metadata][repository][batch][document]") {
MetadataRepositoryFixture fix;
auto parent = makeDocumentWithPath("/tmp/batch-parent.txt", "batch-parent-hash");
auto child = makeDocumentWithPath("/tmp/batch-child.txt", "batch-child-hash");
auto survivor = makeDocumentWithPath("/tmp/batch-survivor.md", "batch-survivor-hash");
auto parentInsert = fix.repository_->insertDocument(parent);
auto childInsert = fix.repository_->insertDocument(child);
auto survivorInsert = fix.repository_->insertDocument(survivor);
REQUIRE((parentInsert.has_value()));
REQUIRE((childInsert.has_value()));
REQUIRE((survivorInsert.has_value()));
const int64_t parentId = parentInsert.value();
const int64_t childId = childInsert.value();
const int64_t survivorId = survivorInsert.value();
REQUIRE(fix.repository_
->insertContent(DocumentContent{parentId, "parent content", 14, "test", "en"})
.has_value());
REQUIRE(
fix.repository_->setMetadata(parentId, "tag:batch", MetadataValue("delete")).has_value());
REQUIRE(fix.repository_->indexDocumentContent(parentId, "parent", "parent content", "text")
.has_value());
DocumentRelationship rel;
rel.parentId = parentId;
rel.childId = childId;
rel.relationshipType = RelationshipType::References;
rel.createdTime = std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
auto relInsert = fix.repository_->insertRelationship(rel);
REQUIRE((relInsert.has_value()));
auto mimeUpdate = fix.repository_->updateDocumentsMimeBatch(
{{childId, "text/markdown"}, {survivorId, "application/json"}, {999999, "ignored/type"}});
REQUIRE((mimeUpdate.has_value()));
CHECK((mimeUpdate.value() == 2));
auto updatedChild = fix.repository_->getDocument(childId);
REQUIRE((updatedChild.has_value()));
REQUIRE((updatedChild.value().has_value()));
CHECK((updatedChild.value()->mimeType == "text/markdown"));
auto deleted = fix.repository_->deleteDocumentsBatch({parentId, 999999});
REQUIRE((deleted.has_value()));
CHECK((deleted.value() == 1));
auto missingParent = fix.repository_->getDocument(parentId);
REQUIRE((missingParent.has_value()));
CHECK_FALSE(missingParent.value().has_value());
auto removedContent = fix.repository_->getContent(parentId);
REQUIRE((removedContent.has_value()));
CHECK_FALSE(removedContent.value().has_value());
auto removedMetadata = fix.repository_->getAllMetadata(parentId);
REQUIRE((removedMetadata.has_value()));
CHECK((removedMetadata.value().empty()));
auto childRels = fix.repository_->getRelationships(childId);
REQUIRE((childRels.has_value()));
CHECK((childRels.value().empty()));
auto survivorDoc = fix.repository_->getDocument(survivorId);
REQUIRE((survivorDoc.has_value()));
REQUIRE((survivorDoc.value().has_value()));
CHECK((survivorDoc.value()->mimeType == "application/json"));
}
TEST_CASE("MetadataRepository: batch get APIs skip missing inputs and keep keyed results",
"[unit][metadata][repository][batch][read]") {
MetadataRepositoryFixture fix;
auto docA = makeDocumentWithPath("/tmp/batch-a.txt", "batch-get-hash-a");
auto docB = makeDocumentWithPath("/tmp/batch-b.txt", "batch-get-hash-b");
auto docC = makeDocumentWithPath("/tmp/batch-c.txt", "batch-get-hash-c");
auto insertA = fix.repository_->insertDocument(docA);
auto insertB = fix.repository_->insertDocument(docB);
auto insertC = fix.repository_->insertDocument(docC);
REQUIRE((insertA.has_value()));
REQUIRE((insertB.has_value()));
REQUIRE((insertC.has_value()));
REQUIRE(fix.repository_
->insertContent(DocumentContent{insertA.value(), "alpha body", 10, "test", "en"})
.has_value());
REQUIRE(fix.repository_
->insertContent(DocumentContent{insertC.value(), "gamma body", 10, "test", "en"})
.has_value());
auto docs = fix.repository_->batchGetDocumentsByHash(
{"batch-get-hash-a", "missing-hash", "batch-get-hash-c", "batch-get-hash-a"});
REQUIRE((docs.has_value()));
CHECK((docs.value().size() == 2));
CHECK((docs.value().at("batch-get-hash-a").id == insertA.value()));
CHECK((docs.value().at("batch-get-hash-c").id == insertC.value()));
CHECK_FALSE(docs.value().contains("missing-hash"));
auto content = fix.repository_->batchGetContent(
{insertA.value(), insertB.value(), insertC.value(), 999999, insertA.value()});
REQUIRE((content.has_value()));
CHECK((content.value().size() == 2));
CHECK((content.value().at(insertA.value()).contentText == "alpha body"));
CHECK((content.value().at(insertC.value()).contentText == "gamma body"));
CHECK_FALSE(content.value().contains(insertB.value()));
CHECK_FALSE(content.value().contains(999999));
auto emptyDocs = fix.repository_->batchGetDocumentsByHash({});
REQUIRE((emptyDocs.has_value()));
CHECK((emptyDocs.value().empty()));
auto emptyContent = fix.repository_->batchGetContent({});
REQUIRE((emptyContent.has_value()));
CHECK((emptyContent.value().empty()));
}
TEST_CASE("MetadataRepository: removeFromIndexByHashBatch reports actual FTS removals",
"[unit][metadata][repository][batch][fts5]") {
MetadataRepositoryFixture fix;
auto indexed = makeDocumentWithPath("/tmp/indexed.txt", "batch-indexed-hash");
auto notIndexed = makeDocumentWithPath("/tmp/not-indexed.txt", "batch-not-indexed-hash");
auto indexedInsert = fix.repository_->insertDocument(indexed);
auto notIndexedInsert = fix.repository_->insertDocument(notIndexed);
REQUIRE((indexedInsert.has_value()));
REQUIRE((notIndexedInsert.has_value()));
REQUIRE(fix.repository_
->indexDocumentContent(indexedInsert.value(), "indexed", "needle", "text/plain")
.has_value());
auto beforeIndexed = fix.repository_->hasFtsEntry(indexedInsert.value());
auto beforeNotIndexed = fix.repository_->hasFtsEntry(notIndexedInsert.value());
REQUIRE((beforeIndexed.has_value()));
REQUIRE((beforeNotIndexed.has_value()));
REQUIRE((beforeIndexed.value()));
REQUIRE_FALSE(beforeNotIndexed.value());
auto removed = fix.repository_->removeFromIndexByHashBatch(
{"batch-indexed-hash", "batch-not-indexed-hash", "missing-hash"});
REQUIRE((removed.has_value()));
CHECK((removed.value() == 1));
auto afterIndexed = fix.repository_->hasFtsEntry(indexedInsert.value());
auto afterNotIndexed = fix.repository_->hasFtsEntry(notIndexedInsert.value());
REQUIRE((afterIndexed.has_value()));
REQUIRE((afterNotIndexed.has_value()));
CHECK_FALSE(afterIndexed.value());
CHECK_FALSE(afterNotIndexed.value());
}
TEST_CASE("MetadataRepository: batchInsertDocumentsWithMetadata coalesces inserts",
"[metadata][batch][insert]") {
MetadataRepositoryFixture fix;
auto makeItem = [](const std::string& path, const std::string& hash, const std::string& tagKey,
const std::string& tagVal, bool pathTree) {
BatchDocumentInsert item;
item.info = makeDocumentWithPath(path, hash);
item.tags.emplace_back(tagKey, MetadataValue(tagVal));
item.updatePathTreeInTransaction = pathTree;
item.initializePathSeriesInTransaction = true;
return item;
};
std::vector<BatchDocumentInsert> items;
items.push_back(makeItem("repo/batch/a.txt", "batch-hash-a", "stage", "alpha", true));
items.push_back(makeItem("repo/batch/b.txt", "batch-hash-b", "stage", "beta", true));
items.push_back(makeItem("repo/batch/c.txt", "batch-hash-c", "stage", "gamma", true));
// Attach a snapshot to the middle item.
TreeSnapshotRecord snap;
snap.snapshotId = "batch-snap-1";
snap.rootTreeHash = "batch-root-hash";
snap.createdTime = 1700000000;
snap.metadata["directory_path"] = "repo/batch";
items[1].snapshot = snap;
auto batchResult = fix.repository_->batchInsertDocumentsWithMetadata(items);
REQUIRE((batchResult.has_value()));
const auto outcomes = batchResult.value();
std::vector<int64_t> ids;
ids.reserve(outcomes.size());
std::ranges::transform(outcomes, std::back_inserter(ids),
[](const auto& outcome) { return outcome.documentId; });
REQUIRE((ids.size() == 3));
CHECK(std::ranges::all_of(outcomes,
[](const auto& outcome) { return outcome.insertedNewDocument; }));
CHECK(std::ranges::all_of(outcomes,
[](const auto& outcome) { return outcome.pathSeriesInitialized; }));
SECTION("ids are valid, distinct, and resolve to the right documents") {
std::vector<int64_t> sorted = ids;
std::sort(sorted.begin(), sorted.end());
CHECK((std::adjacent_find(sorted.begin(), sorted.end()) == sorted.end()));
for (auto id : ids) {
CHECK((id > 0));
}
const std::vector<std::string> hashes = {"batch-hash-a", "batch-hash-b", "batch-hash-c"};
for (std::size_t i = 0; i < hashes.size(); ++i) {
auto doc = fix.repository_->getDocumentByHash(hashes[i]);
REQUIRE((doc.has_value()));
REQUIRE((doc.value().has_value()));
CHECK((doc.value().value().id == ids[i]));
}
}
SECTION("metadata tags are persisted for each document") {
const std::vector<std::pair<int64_t, std::string>> expected = {
{ids[0], "alpha"}, {ids[1], "beta"}, {ids[2], "gamma"}};
for (const auto& [id, val] : expected) {
auto mv = fix.repository_->getMetadata(id, "stage");
REQUIRE((mv.has_value()));
REQUIRE((mv.value().has_value()));
CHECK((mv.value().value().value == val));
}
}
SECTION("fresh paths initialize canonical path-series metadata") {
for (std::size_t i = 0; i < ids.size(); ++i) {
auto version = fix.repository_->getMetadata(ids[i], "version");
auto latest = fix.repository_->getMetadata(ids[i], "is_latest");
auto seriesKey = fix.repository_->getMetadata(ids[i], "series_key");
REQUIRE((version.has_value()));
REQUIRE((version.value().has_value()));
REQUIRE((latest.has_value()));
REQUIRE((latest.value().has_value()));
REQUIRE((seriesKey.has_value()));
REQUIRE((seriesKey.value().has_value()));
CHECK((version.value()->asInteger() == 1));
CHECK((latest.value()->asBoolean()));
CHECK((seriesKey.value()->asString() == items[i].info.filePath));
}
}
SECTION("snapshot attached to one item is committed and bound to its document") {
// All-or-nothing batch committed (batchResult ok), so the tree_snapshots row was written
// without constraint failure. Verify the in-transaction wiring set ingestDocumentId.
REQUIRE((items[1].snapshot.has_value()));
REQUIRE((items[1].snapshot->ingestDocumentId.has_value()));
CHECK((items[1].snapshot->ingestDocumentId.value() == ids[1]));
}
SECTION("existing-hash item in a later batch dedups to the same id") {
std::vector<BatchDocumentInsert> dupItems;
dupItems.push_back(makeItem("repo/batch/a.txt", "batch-hash-a", "stage", "alpha", true));
auto dupResult = fix.repository_->batchInsertDocumentsWithMetadata(dupItems);
REQUIRE((dupResult.has_value()));
REQUIRE((dupResult.value().size() == 1));
CHECK((dupResult.value()[0].documentId == ids[0]));
CHECK_FALSE(dupResult.value()[0].insertedNewDocument);
CHECK_FALSE(dupResult.value()[0].pathSeriesInitialized);
}
SECTION("a new hash at an existing path remains pending version assignment") {
std::vector<BatchDocumentInsert> nextVersion;
nextVersion.push_back(
makeItem("repo/batch/a.txt", "batch-hash-a-v2", "stage", "delta", true));
auto nextResult = fix.repository_->batchInsertDocumentsWithMetadata(nextVersion);
REQUIRE((nextResult.has_value()));
REQUIRE((nextResult.value().size() == 1));
CHECK(nextResult.value()[0].insertedNewDocument);
CHECK_FALSE(nextResult.value()[0].pathSeriesInitialized);
auto version = fix.repository_->getMetadata(nextResult.value()[0].documentId, "version");
REQUIRE((version.has_value()));
CHECK_FALSE(version.value().has_value());
}
SECTION("empty batch is a no-op success") {
std::vector<BatchDocumentInsert> empty;
auto emptyResult = fix.repository_->batchInsertDocumentsWithMetadata(empty);
REQUIRE((emptyResult.has_value()));
CHECK((emptyResult.value().empty()));
}
}
TEST_CASE("MetadataInsertWriter fallback preserves single-document insert outcome",
"[metadata][batch][writer][fallback]") {
auto dbPath = tempDbPath("metadata_insert_writer_fallback_outcome_");
ConnectionPoolConfig config;
config.minConnections = 1;
config.maxConnections = 2;
auto pool = std::make_shared<ConnectionPool>(dbPath.string(), config);
REQUIRE((pool->initialize().has_value()));
auto repo = std::make_shared<MetadataRepository>(
*pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
BatchDocumentInsert clean;
clean.info = makeDocumentWithPath("repo/writer/fallback-clean.txt", "writer-fallback-clean");
clean.initializePathSeriesInTransaction = true;
BatchDocumentInsert poison;
poison.info = makeDocumentWithPath("repo/writer/fallback-poison.txt", "writer-fallback-poison");
TreeSnapshotRecord poisonSnapshot;
poisonSnapshot.snapshotId = "writer-fallback-poison-snapshot";
poisonSnapshot.rootTreeHash = "root";
poisonSnapshot.createdTime = 1;
poisonSnapshot.fileCount = 1;
poison.snapshot = poisonSnapshot;
// No directory_path metadata: tree_snapshots.directory_path is NOT NULL. This poisons the
// coalesced batch transaction while leaving the clean document valid for per-item fallback.
{
MetadataInsertWriter writer(
repo, MetadataInsertWriter::Options{.maxBatchCount = 2,
.maxDelay = std::chrono::milliseconds{50}});
auto cleanFuture = writer.submit(clean);
auto poisonFuture = writer.submit(poison);
auto cleanResult = cleanFuture.get();
REQUIRE((cleanResult.has_value()));
CHECK((cleanResult.value().documentId > 0));
CHECK(cleanResult.value().insertedNewDocument);
CHECK(cleanResult.value().pathSeriesInitialized);
auto poisonResult = poisonFuture.get();
REQUIRE_FALSE((poisonResult.has_value()));
REQUIRE(writer.flush());
const auto metrics = writer.metricsSnapshot();
CHECK(metrics.failedBatches == 1);
CHECK(metrics.fallbackItems == 2);
}
auto cleanDoc = repo->getDocumentByHash("writer-fallback-clean");
REQUIRE((cleanDoc.has_value()));
REQUIRE((cleanDoc.value().has_value()));
auto version = repo->getMetadata(cleanDoc.value()->id, "version");
REQUIRE((version.has_value()));
CHECK((version.value().has_value()));
auto poisonDoc = repo->getDocumentByHash("writer-fallback-poison");
REQUIRE((poisonDoc.has_value()));
CHECK_FALSE((poisonDoc.value().has_value()));
pool->shutdown();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("MetadataInsertWriter coalesces concurrent submits into correct per-doc ids",
"[metadata][batch][writer]") {
auto dbPath = tempDbPath("metadata_insert_writer_test_");
ConnectionPoolConfig config;
config.minConnections = 1;
config.maxConnections = 2;
auto pool = std::make_shared<ConnectionPool>(dbPath.string(), config);
REQUIRE((pool->initialize().has_value()));
auto repo = std::make_shared<MetadataRepository>(
*pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
resetMetadataInsertPhaseTimings();
constexpr int kDocs = 64;
{
MetadataInsertWriter writer(
repo, MetadataInsertWriter::Options{.maxBatchCount = kDocs,
.maxDelay = std::chrono::milliseconds{50}});
// Submit from several threads to exercise concurrent coalescing.
std::atomic<int> next{0};
std::mutex futMutex;
std::vector<std::future<Result<DocumentInsertOutcome>>> collected;
collected.reserve(kDocs);
constexpr int kThreads = 8;
std::vector<std::thread> submitters;
for (int t = 0; t < kThreads; ++t) {
submitters.emplace_back([&]() {
while (true) {
int i = next.fetch_add(1);
if (i >= kDocs)
break;
auto fut = writer.submit(makeIndexedBatchInsert(i));
std::lock_guard<std::mutex> lk(futMutex);
collected.push_back(std::move(fut));
}
});
}
for (auto& th : submitters)
th.join();
std::vector<int64_t> ids;
ids.reserve(kDocs);
for (auto& f : collected) {
auto r = f.get();
REQUIRE((r.has_value()));
CHECK((r.value().documentId > 0));
CHECK(r.value().insertedNewDocument);
ids.push_back(r.value().documentId);
}
REQUIRE((static_cast<int>(ids.size()) == kDocs));
std::sort(ids.begin(), ids.end());
CHECK((std::adjacent_find(ids.begin(), ids.end()) == ids.end()));
REQUIRE(writer.flush());
const auto metrics = writer.metricsSnapshot();
CHECK(metrics.submittedItems == kDocs);
CHECK(metrics.completedItems == kDocs);
CHECK(metrics.batchItems == kDocs);
CHECK(metrics.batches > 0);
CHECK(metrics.batches < kDocs);
CHECK(metrics.maxBatchSize > 1);
CHECK(metrics.queueWaitSamples == kDocs);
CHECK(metrics.batchApplySamples == metrics.batches);
CHECK(metrics.failedBatches == 0);
CHECK(metrics.fallbackItems == 0);
const auto phaseTimings = getMetadataInsertPhaseTimingsSnapshot();
const auto transaction = phaseTimings.find("transaction_total");
REQUIRE(transaction != phaseTimings.end());
CHECK(transaction->second.calls == metrics.batches);
CHECK(transaction->second.totalUs >= phaseTimings.at("commit").totalUs);
writer.resetMetrics();
const auto reset = writer.metricsSnapshot();
CHECK(reset.submittedItems == 0);
CHECK(reset.completedItems == 0);
CHECK(reset.batches == 0);
CHECK(reset.batchItems == 0);
}
// All documents are durable after the writer drains.
for (int i = 0; i < kDocs; ++i) {
auto doc = repo->getDocumentByHash("writer-hash-" + std::to_string(i));
REQUIRE((doc.has_value()));
CHECK((doc.value().has_value()));
}
pool->shutdown();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE("ContentIndexWriter commits content-index chunks from concurrent submits",
"[metadata][batch][writer][content]") {
auto dbPath = tempDbPath("content_index_writer_test_");
ConnectionPoolConfig config;
config.minConnections = 1;
config.maxConnections = 2;
auto pool = std::make_shared<ConnectionPool>(dbPath.string(), config);
REQUIRE((pool->initialize().has_value()));
auto repo = std::make_shared<MetadataRepository>(
*pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady);
repo->initializeCounters();
constexpr int kDocs = 32;
std::vector<int64_t> docIds;
for (int i = 0; i < kDocs; ++i) {
DocumentInfo doc;
doc.sha256Hash = "ciw-hash-" + std::to_string(i);
doc.fileName = "ciw-" + std::to_string(i) + ".txt";
doc.fileSize = 100;
doc.mimeType = "text/plain";
doc.createdTime =
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
doc.modifiedTime = doc.createdTime;
doc.contentExtracted = false;
doc.extractionStatus = ExtractionStatus::Pending;
auto r = repo->insertDocument(doc);
REQUIRE((r.has_value()));
docIds.push_back(r.value());
}
{
ContentIndexWriter writer(repo);
std::atomic<int> next{0};
std::mutex futMutex;
std::vector<std::future<Result<void>>> collected;
std::vector<std::thread> submitters;
for (int t = 0; t < 6; ++t) {
submitters.emplace_back([&]() {
while (true) {
int i = next.fetch_add(1);
if (i >= kDocs)
break;
std::vector<BatchContentEntry> chunk{makeBatchContentEntry(
docIds[static_cast<size_t>(i)], "Title " + std::to_string(i),
"Content body " + std::to_string(i))};
auto fut = writer.submit(std::move(chunk));
std::lock_guard<std::mutex> lk(futMutex);
collected.push_back(std::move(fut));
}
});
}
for (auto& th : submitters)
th.join();
for (auto& f : collected) {
auto r = f.get();
CHECK((r.has_value()));
}
}
// Every document is now FTS-indexed after the writer drains.
for (int i = 0; i < kDocs; ++i) {
auto fts = repo->hasFtsEntry(docIds[static_cast<size_t>(i)]);
REQUIRE((fts.has_value()));
CHECK((fts.value()));
}
pool->shutdown();
std::error_code ec;
std::filesystem::remove(dbPath, ec);
}
TEST_CASE_METHOD(MetadataRepositoryFixture,
"MetadataRepository publishes its knowledge graph store concurrently",
"[metadata][repository][concurrency]") {
auto kgStoreResult = makeSqliteKnowledgeGraphStore(*pool_);
REQUIRE((kgStoreResult.has_value()));
std::shared_ptr<KnowledgeGraphStore> kgStore = std::move(kgStoreResult.value());
std::atomic<bool> start{false};
std::atomic<bool> stop{false};
std::atomic<bool> sawPublishedStore{false};
std::atomic<bool> sawInvalidStore{false};
std::thread reader([&]() {
while (!start.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
while (!stop.load(std::memory_order_acquire)) {
const auto snapshot = repository_->getKnowledgeGraphStore();
if (snapshot) {
if (snapshot != kgStore) {
sawInvalidStore.store(true, std::memory_order_release);
}
sawPublishedStore.store(true, std::memory_order_release);
}
}
});
start.store(true, std::memory_order_release);
for (int i = 0; i < 1000; ++i) {
repository_->setKnowledgeGraphStore(kgStore);
repository_->setKnowledgeGraphStore(nullptr);
}
repository_->setKnowledgeGraphStore(kgStore);
while (!sawPublishedStore.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
stop.store(true, std::memory_order_release);
reader.join();
CHECK_FALSE(sawInvalidStore.load(std::memory_order_acquire));
CHECK((repository_->getKnowledgeGraphStore() == kgStore));
}
// NOLINTEND(bugprone-chained-comparison)