* fix(daemon): resolve sign-conversion warnings * fix(daemon): add IO thread startup barrier * refactor: consolidate toLowerCopy clones * fix(daemon): add atexit handler for static host map cleanup * fix(build): add missing include, fix daemon_helpers syntax, use atomic barrier * feat(docs): docs cleanup, and metadata refactor updates * fix(daemon): resolve Windows-only PostIngestQueue shutdown SIGSEGV (#45) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat: submodule bump, multiclient optimizations and benchmark optimizations * feat(perf): reduced contention with update to placement of flushPathCacheBuffer() * fix(metadata): invalidate path cache on document mutation * fix(metadata): hydrate cached counters before first write --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
9 KiB
C++
250 lines
9 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright 2025 YAMS Contributors
|
|
|
|
// Smoke test for path cache snapshot behavior via findDocumentByExactPath
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/metadata/path_utils.h>
|
|
#include <yams/storage/corpus_stats.h>
|
|
|
|
using namespace std::chrono;
|
|
using namespace yams::metadata;
|
|
|
|
namespace {
|
|
|
|
std::filesystem::path tempDbPath(const char* prefix) {
|
|
const char* t = std::getenv("YAMS_TEST_TMPDIR");
|
|
auto base = (t && *t) ? std::filesystem::path(t) : std::filesystem::temp_directory_path();
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(base, ec);
|
|
auto ts = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
auto p = base / (std::string(prefix) + std::to_string(ts) + ".db");
|
|
std::filesystem::remove(p, ec);
|
|
return p;
|
|
}
|
|
|
|
DocumentInfo mk(const std::string& path, const std::string& hash) {
|
|
DocumentInfo d;
|
|
d.filePath = path;
|
|
auto derived = computePathDerivedValues(path);
|
|
d.fileName = std::filesystem::path(derived.normalizedPath).filename().string();
|
|
d.fileExtension = std::filesystem::path(d.fileName).extension().string();
|
|
d.sha256Hash = hash;
|
|
d.pathPrefix = derived.pathPrefix;
|
|
d.reversePath = derived.reversePath;
|
|
d.pathHash = derived.pathHash;
|
|
d.parentHash = derived.parentHash;
|
|
d.pathDepth = derived.pathDepth;
|
|
auto now = floor<seconds>(system_clock::now());
|
|
d.createdTime = d.modifiedTime = d.indexedTime = now;
|
|
return d;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("MetadataRepositoryCache: repeated exact path hits cache", "[unit][metadata][cache]") {
|
|
auto dbPath = tempDbPath("repo_cache_catch2_");
|
|
ConnectionPool pool(dbPath.string());
|
|
REQUIRE(pool.initialize().has_value());
|
|
MetadataRepository repo(pool);
|
|
|
|
auto id = repo.insertDocument(mk("/cache/hit.md", "H1"));
|
|
REQUIRE(id.has_value());
|
|
|
|
// First call should populate cache
|
|
auto first = repo.findDocumentByExactPath("/cache/hit.md");
|
|
REQUIRE(first.has_value());
|
|
REQUIRE(first.value().has_value());
|
|
auto d1 = first.value().value();
|
|
CHECK((d1.filePath == "/cache/hit.md"));
|
|
|
|
// Second call should return same doc (served from cache path in repo)
|
|
auto second = repo.findDocumentByExactPath("/cache/hit.md");
|
|
REQUIRE(second.has_value());
|
|
REQUIRE(second.value().has_value());
|
|
auto d2 = second.value().value();
|
|
CHECK((d2.filePath == d1.filePath));
|
|
CHECK((d2.sha256Hash == d1.sha256Hash));
|
|
|
|
// Cleanup
|
|
pool.shutdown();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|
|
|
|
TEST_CASE("MetadataRepositoryCache: mutations invalidate cached path entries",
|
|
"[unit][metadata][cache]") {
|
|
auto dbPath = tempDbPath("repo_cache_invalidate_catch2_");
|
|
ConnectionPool pool(dbPath.string());
|
|
REQUIRE(pool.initialize().has_value());
|
|
MetadataRepository repo(pool);
|
|
|
|
constexpr int kDocs = 40; // > flush threshold (32) so entries land in the snapshot
|
|
std::vector<int64_t> ids;
|
|
for (int i = 0; i < kDocs; ++i) {
|
|
auto path = "/cache/inv_" + std::to_string(i) + ".md";
|
|
auto id = repo.insertDocument(mk(path, "H" + std::to_string(i)));
|
|
REQUIRE(id.has_value());
|
|
ids.push_back(id.value());
|
|
}
|
|
for (int i = 0; i < kDocs; ++i) {
|
|
auto path = "/cache/inv_" + std::to_string(i) + ".md";
|
|
auto found = repo.findDocumentByExactPath(path);
|
|
REQUIRE(found.has_value());
|
|
REQUIRE(found.value().has_value());
|
|
}
|
|
|
|
SECTION("deleteDocument removes the cached entry") {
|
|
REQUIRE(repo.deleteDocument(ids[5]).has_value());
|
|
auto after = repo.findDocumentByExactPath("/cache/inv_5.md");
|
|
REQUIRE(after.has_value());
|
|
CHECK_FALSE(after.value().has_value());
|
|
}
|
|
|
|
SECTION("updateDocument refreshes the cached entry") {
|
|
auto current = repo.findDocumentByExactPath("/cache/inv_6.md");
|
|
REQUIRE(current.has_value());
|
|
REQUIRE(current.value().has_value());
|
|
auto doc = current.value().value();
|
|
doc.sha256Hash = "H6_updated";
|
|
REQUIRE(repo.updateDocument(doc).has_value());
|
|
|
|
auto after = repo.findDocumentByExactPath("/cache/inv_6.md");
|
|
REQUIRE(after.has_value());
|
|
REQUIRE(after.value().has_value());
|
|
CHECK((after.value()->sha256Hash == "H6_updated"));
|
|
}
|
|
|
|
SECTION("rename invalidates the old path") {
|
|
auto current = repo.findDocumentByExactPath("/cache/inv_7.md");
|
|
REQUIRE(current.has_value());
|
|
REQUIRE(current.value().has_value());
|
|
auto doc = current.value().value();
|
|
auto derived = computePathDerivedValues("/cache/renamed_7.md");
|
|
doc.filePath = "/cache/renamed_7.md";
|
|
doc.fileName = "renamed_7.md";
|
|
doc.pathPrefix = derived.pathPrefix;
|
|
doc.reversePath = derived.reversePath;
|
|
doc.pathHash = derived.pathHash;
|
|
doc.parentHash = derived.parentHash;
|
|
doc.pathDepth = derived.pathDepth;
|
|
REQUIRE(repo.updateDocument(doc).has_value());
|
|
|
|
auto oldPath = repo.findDocumentByExactPath("/cache/inv_7.md");
|
|
REQUIRE(oldPath.has_value());
|
|
CHECK_FALSE(oldPath.value().has_value());
|
|
|
|
auto newPath = repo.findDocumentByExactPath("/cache/renamed_7.md");
|
|
REQUIRE(newPath.has_value());
|
|
CHECK(newPath.value().has_value());
|
|
}
|
|
|
|
SECTION("deleteDocumentsBatch removes cached entries") {
|
|
REQUIRE(repo.deleteDocumentsBatch({ids[8], ids[9]}).has_value());
|
|
auto a = repo.findDocumentByExactPath("/cache/inv_8.md");
|
|
REQUIRE(a.has_value());
|
|
CHECK_FALSE(a.value().has_value());
|
|
auto b = repo.findDocumentByExactPath("/cache/inv_9.md");
|
|
REQUIRE(b.has_value());
|
|
CHECK_FALSE(b.value().has_value());
|
|
}
|
|
|
|
pool.shutdown();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|
|
|
|
TEST_CASE("MetadataRepository: fresh instance over existing DB hydrates counters on first write",
|
|
"[unit][metadata][counters]") {
|
|
auto dbPath = tempDbPath("repo_counter_hydrate_catch2_");
|
|
ConnectionPool pool(dbPath.string());
|
|
REQUIRE(pool.initialize().has_value());
|
|
|
|
std::vector<int64_t> ids;
|
|
{
|
|
MetadataRepository primary(pool);
|
|
primary.initializeCounters();
|
|
for (int i = 0; i < 3; ++i) {
|
|
auto id = primary.insertDocument(
|
|
mk("/counters/doc_" + std::to_string(i) + ".md", "CH" + std::to_string(i)));
|
|
REQUIRE(id.has_value());
|
|
ids.push_back(id.value());
|
|
}
|
|
REQUIRE(primary
|
|
.setMetadata(ids[0], "tag:primary", MetadataValue(std::string("true")))
|
|
.has_value());
|
|
}
|
|
|
|
// Second instance over the same DB, attached without initializeCounters()
|
|
// — the stateless-CLI case. Tagging a previously untagged document bumps
|
|
// docsWithTags by +1; the counters must be hydrated from the DB first or
|
|
// the kv_ops invariant (docsWithTags <= docCount) fires against a
|
|
// still-zero document count (previous behavior: SIGABRT).
|
|
MetadataRepository fresh(pool);
|
|
auto setRes = fresh.setMetadata(ids[1], "tag:fresh", MetadataValue(std::string("true")));
|
|
REQUIRE(setRes.has_value());
|
|
|
|
auto count = fresh.getDocumentCount();
|
|
REQUIRE(count.has_value());
|
|
CHECK(count.value() == 3);
|
|
|
|
auto stats = fresh.getCorpusStats();
|
|
REQUIRE(stats.has_value());
|
|
CHECK(stats.value().docCount == 3);
|
|
CHECK(stats.value().docsWithTags >= 1);
|
|
CHECK(stats.value().docsWithTags <= stats.value().docCount);
|
|
|
|
pool.shutdown();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|
|
|
|
TEST_CASE("MetadataRepositoryCache: concurrent exact path lookups stay coherent",
|
|
"[unit][metadata][cache][concurrency]") {
|
|
auto dbPath = tempDbPath("repo_cache_concurrent_catch2_");
|
|
ConnectionPool pool(dbPath.string());
|
|
REQUIRE(pool.initialize().has_value());
|
|
MetadataRepository repo(pool);
|
|
|
|
auto id = repo.insertDocument(mk("/cache/concurrent.md", "HC"));
|
|
REQUIRE(id.has_value());
|
|
|
|
std::atomic<bool> start{false};
|
|
std::atomic<int> failures{0};
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(8);
|
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
threads.emplace_back([&]() {
|
|
while (!start.load(std::memory_order_acquire)) {
|
|
std::this_thread::yield();
|
|
}
|
|
for (int iter = 0; iter < 200; ++iter) {
|
|
auto result = repo.findDocumentByExactPath("/cache/concurrent.md");
|
|
if (!result || !result.value().has_value() || result.value()->sha256Hash != "HC") {
|
|
failures.fetch_add(1, std::memory_order_relaxed);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
start.store(true, std::memory_order_release);
|
|
for (auto& thread : threads) {
|
|
thread.join();
|
|
}
|
|
|
|
CHECK((failures.load(std::memory_order_relaxed) == 0));
|
|
|
|
pool.shutdown();
|
|
std::error_code ec;
|
|
std::filesystem::remove(dbPath, ec);
|
|
}
|