yams/tests/unit/daemon/service_manager_catch2_test.cpp
Trevon 4f6534f65a
fix(daemon): mark the vector DB ready once its first vectors commit (#348)
readiness.vectorDbReady was set only at vector DB init, as "rows > 0".
A corpus that starts empty kept it false after every document was
embedded: status and doctor reported the vector DB not ready until a
restart, and SearchComponent already worked around it by probing the
live row count.

Set it when the embedding service reports committed vectors, once the
vector DB has been initialized; ServiceManager owns the state and
already handles that callback.

The retrieval bench waits up to 30 s for vector_db_ready before its
queries; every run hit the full wait. On grim (gcc Profiling,
search_product_clean_baseline, base is #346):

                         SciFact              NFCorpus
  setup total            56.4 -> 27.7 s       411.7 -> 382.1 s
  searchability ready    54.6 -> 25.3 s       409.8 -> 380.2 s
  guard waits            3/3 -> 0/3           3/3 -> 0/3
  MAP / MRR / nDCG       identical            within base run-to-run range
  latency p50 / p95      41.2/54.0 ->         29.8/59.3 ->
                         47.0/66.7 ms         29.3/59.0 ms

The SciFact queries now start right after ingest instead of after the
idle guard. In-window background zones stay under 0.1 s; the query
path is unchanged, and NFCorpus latency is flat.
2026-10-25 14:58:26 -06:00

2082 lines
89 KiB
C++

// Catch2 migration of service_manager_test.cpp
// Migration: yams-3s4 (daemon unit tests)
// Unit tests for ServiceManager component - construction, initialization, and service access
// pi-lens-ignore: fatal error
#include <catch2/catch_test_macros.hpp>
#include <sqlite3.h>
#include <nlohmann/json.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/post.hpp>
#include <boost/asio/use_future.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <exception>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <mutex>
#include <set>
#include <stdexcept>
#include <system_error>
#include <thread>
#ifdef _WIN32
#include <aclapi.h>
#include <windows.h>
#endif
#include "../../common/test_helpers_catch2.h"
#include "../../../include/yams/daemon/components/db_integrity_stamp.h"
#include "../../../src/daemon/components/service_manager/bootstrap_status.h"
#include <yams/compat/thread_stop_compat.h>
#include <yams/crypto/hasher.h>
#include <yams/daemon/components/ConfigResolver.h>
#include <yams/daemon/components/DaemonLifecycleFsm.h>
#include <yams/daemon/components/db_recovery.h>
#include <yams/daemon/components/InternalEventBus.h>
#include <yams/daemon/components/repair/repair_health_probe.h>
#include <yams/daemon/components/RepairService.h>
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/components/StateComponent.h>
#include <yams/daemon/components/TuneAdvisor.h>
#include <yams/daemon/components/WorkCoordinator.h>
#include <yams/daemon/daemon.h>
#include <yams/daemon/shutdown_budget.h>
#include <yams/memory_sync/memory_sync_service.h>
#include <yams/memory_sync/writer_auth.h>
#include <yams/metadata/database.h>
namespace fs = std::filesystem;
using namespace yams;
using namespace yams::daemon;
namespace yams::daemon::test {
namespace {
TEST_CASE("Bootstrap status snapshot preserves readiness schema and ETA values",
"[daemon][service_manager][status][eta]") {
using service_manager::BootstrapStatusData;
const auto start = std::chrono::steady_clock::time_point{std::chrono::seconds{100}};
const auto now = start + std::chrono::seconds{2};
BootstrapStatusData data;
data.dataDir = "/tmp/yams-data";
data.startTime = start;
data.readiness.ipcServerReady = true;
data.readiness.contentStoreReady = true;
data.readiness.vectorDbInitAttempted = true;
data.readiness.vectorDbReady = true;
data.readiness.vectorDbDim = 768;
data.readiness.searchProgress = 100;
data.readiness.vectorIndexProgress = 50;
data.readiness.modelLoadProgress = 25;
data.freshness = service_manager::BootstrapFreshnessSnapshot{
.simeonLexicalConfigured = true,
.simeonLexicalReady = false,
.simeonLexicalBuilding = true,
.simeonFragmentGeometryReady = false,
};
data.databaseRecoveredAt = "2026-01-02T03:04:05Z";
data.databaseRecoveredFrom = "/tmp/yams.db.corrupt";
data.databasePhase = "migrating";
data.databasePhaseSince = now - std::chrono::milliseconds{750};
data.maintenancePhase = "recovery_cleanup";
data.maintenancePhaseSince = now - std::chrono::milliseconds{250};
data.storageWarning = "slow storage";
data.initDurationsMs = {{"vector_index", 7000}, {"database", 1500}, {"plugins", 500}};
const auto status = service_manager::buildBootstrapStatusSnapshot(data, now);
CHECK_FALSE(status.at("ready").get<bool>());
CHECK(status.at("overall") == "initializing");
const auto& readiness = status.at("readiness");
CHECK(readiness.at("ipc_server") == true);
CHECK(readiness.at("content_store") == true);
CHECK(readiness.at("database") == false);
CHECK(readiness.at("metadata_repo") == false);
CHECK(readiness.at("search_engine") == false);
CHECK(readiness.at("model_provider") == false);
CHECK(readiness.at("vector_index") == false);
CHECK(readiness.at("plugins") == false);
CHECK(readiness.at("vector_db_init_attempted") == true);
CHECK(readiness.at("vector_db_ready") == true);
CHECK(readiness.at("vector_db_dim") == 768);
CHECK(readiness.at("search_engine_lexical_enhancement_configured") == true);
CHECK(readiness.at("search_engine_lexical_enhancement_ready") == false);
CHECK(readiness.at("search_engine_lexical_enhancement_building") == true);
CHECK(readiness.at("search_engine_fragment_geometry_ready") == false);
CHECK(status.at("search_engine_lexical_enhancement_state") == "building");
CHECK(status.at("database_recovered_at") == "2026-01-02T03:04:05Z");
CHECK(status.at("database_recovered_from") == "/tmp/yams.db.corrupt");
CHECK(status.at("database_phase") == "migrating");
CHECK(status.at("database_phase_elapsed_ms") == 750);
CHECK(status.at("maintenance_phase") == "recovery_cleanup");
CHECK(status.at("maintenance_phase_elapsed_ms") == 250);
CHECK(status.at("storage_warning") == "slow storage");
CHECK(status.at("progress").at("search_engine") == 100);
CHECK(status.at("progress").at("vector_index") == 50);
CHECK(status.at("progress").at("model_provider") == 25);
CHECK(status.at("eta_seconds").at("vector_index") == 5);
CHECK(status.at("eta_seconds").at("model_provider") == 18);
CHECK_FALSE(status.at("eta_seconds").contains("content_store"));
CHECK(status.at("durations_ms").at("database") == 1500);
CHECK(status.at("top_slowest").at(0).at("name") == "vector_index");
CHECK(status.at("top_slowest").at(0).at("elapsed_ms") == 7000);
CHECK(status.at("uptime_seconds") == 2);
CHECK(status.at("data_dir") == "/tmp/yams-data");
}
TEST_CASE("Bootstrap status throttles only pre-ready publication",
"[daemon][service_manager][status][throttle]") {
const auto previous = std::chrono::steady_clock::time_point{std::chrono::milliseconds{1000}};
CHECK_FALSE(service_manager::shouldPublishBootstrapStatus(
false, previous, previous + std::chrono::milliseconds{249}));
CHECK(service_manager::shouldPublishBootstrapStatus(false, previous,
previous + std::chrono::milliseconds{250}));
CHECK(service_manager::shouldPublishBootstrapStatus(true, previous,
previous + std::chrono::milliseconds{1}));
CHECK(service_manager::shouldPublishBootstrapStatus(
false, std::chrono::steady_clock::time_point{}, previous));
}
TEST_CASE("Bootstrap status snapshot publishes completed readiness immediately",
"[daemon][service_manager][status][ready]") {
service_manager::BootstrapStatusData data;
data.readiness.ipcServerReady = true;
data.readiness.contentStoreReady = true;
data.readiness.databaseReady = true;
data.readiness.metadataRepoReady = true;
data.readiness.searchEngineReady = true;
data.readiness.modelProviderReady = true;
data.readiness.vectorIndexReady = true;
data.readiness.pluginsReady = true;
data.freshness = service_manager::BootstrapFreshnessSnapshot{};
const auto status = service_manager::buildBootstrapStatusSnapshot(data, data.startTime);
CHECK(status.at("ready") == true);
CHECK(status.at("overall") == "ready");
CHECK(status.at("eta_seconds").empty());
CHECK(status.at("search_engine_lexical_enhancement_state") == "disabled");
}
fs::path metadataDbPath(const DaemonConfig& config) {
return config.dataDir / "yams.db";
}
void seedMetadataDb(const fs::path& dbPath, const std::string& value = "seed") {
yams::metadata::Database db;
REQUIRE(db.open(dbPath.string(), yams::metadata::ConnectionMode::Create));
REQUIRE(
db.execute("CREATE TABLE IF NOT EXISTS startup_probe(id INTEGER PRIMARY KEY, value TEXT)"));
REQUIRE(db.execute("DELETE FROM startup_probe"));
REQUIRE(db.execute("INSERT INTO startup_probe(value) VALUES('" + value + "')"));
REQUIRE(db.execute("PRAGMA wal_checkpoint(TRUNCATE)"));
db.close();
REQUIRE(clearDbCleanShutdownSidecars(dbPath));
}
void writeCorruptDb(const fs::path& dbPath) {
std::ofstream out(dbPath, std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
static constexpr char kSqliteHeader[] = "SQLite format 3";
out.write(kSqliteHeader, static_cast<std::streamsize>(sizeof(kSqliteHeader)));
const std::string garbage(4096, '\xff');
out.write(garbage.data(), static_cast<std::streamsize>(garbage.size()));
out.close();
}
std::optional<fs::path> findQuarantinedFile(const fs::path& dataDir) {
std::error_code ec;
if (!fs::exists(dataDir, ec)) {
return std::nullopt;
}
const std::string prefix = yams::daemon::corruptDbPrefix();
for (fs::directory_iterator it(dataDir, ec), end; it != end; it.increment(ec)) {
if (ec) {
ec.clear();
continue;
}
const auto name = it->path().filename().string();
if (name.starts_with(prefix) && !name.ends_with("-wal") && !name.ends_with("-shm")) {
return it->path();
}
}
return std::nullopt;
}
std::size_t startupProbeRowCount(const fs::path& dbPath) {
yams::metadata::Database db;
REQUIRE(db.open(dbPath.string(), yams::metadata::ConnectionMode::ReadWrite));
auto stmtR = db.prepare("SELECT COUNT(*) FROM startup_probe");
REQUIRE(stmtR);
auto& stmt = stmtR.value();
REQUIRE(stmt.step());
auto count = static_cast<std::size_t>(stmt.getInt(0));
db.close();
return count;
}
void seedDummyWalSidecar(const fs::path& dbPath, const std::string& payload = "stale-wal") {
std::ofstream out(dbPath.string() + "-wal", std::ios::binary | std::ios::trunc);
REQUIRE(out.good());
out.write(payload.data(), static_cast<std::streamsize>(payload.size()));
out.close();
REQUIRE(fs::exists(dbPath.string() + "-wal"));
}
bool walSidecarCleared(const fs::path& dbPath) {
std::error_code ec;
const fs::path walPath(dbPath.string() + "-wal");
if (!fs::exists(walPath, ec)) {
return true;
}
return fs::file_size(walPath, ec) == 0;
}
bool walSidecarStillMatchesPayload(const fs::path& dbPath, const std::string& payload) {
const fs::path walPath(dbPath.string() + "-wal");
std::error_code ec;
if (!fs::exists(walPath, ec) || fs::file_size(walPath, ec) != payload.size()) {
return false;
}
std::ifstream in(walPath, std::ios::binary);
std::string actual(payload.size(), '\0');
in.read(actual.data(), static_cast<std::streamsize>(actual.size()));
return in.good() && actual == payload;
}
class SessionWatchIndexingService final : public app::services::IIndexingService {
public:
Result<app::services::AddDirectoryResponse>
addDirectory(const app::services::AddDirectoryRequest& request) override {
requests.push_back(request);
if (failuresRemaining > 0) {
--failuresRemaining;
return Error{ErrorCode::IOError, "simulated indexing failure"};
}
app::services::AddDirectoryResponse response;
response.filesProcessed = request.includePatterns.size();
for (const auto& relative : request.includePatterns) {
app::services::IndexedFileResult result;
result.path = (fs::path(request.directoryPath) / relative).string();
result.success = !failingFiles.contains(relative);
if (result.success) {
++response.filesIndexed;
} else {
++response.filesFailed;
result.error = "simulated per-file failure";
}
response.results.push_back(std::move(result));
}
return response;
}
std::size_t failuresRemaining{0};
std::set<std::string> failingFiles;
std::vector<app::services::AddDirectoryRequest> requests;
};
class SessionWatchDocumentService final : public app::services::IDocumentService {
public:
Result<app::services::StoreDocumentResponse>
store(const app::services::StoreDocumentRequest&) override {
return ErrorCode::NotImplemented;
}
Result<app::services::RetrieveDocumentResponse>
retrieve(const app::services::RetrieveDocumentRequest&) override {
return ErrorCode::NotImplemented;
}
Result<app::services::CatDocumentResponse>
cat(const app::services::CatDocumentRequest&) override {
return ErrorCode::NotImplemented;
}
Result<app::services::ListDocumentsResponse>
list(const app::services::ListDocumentsRequest&) override {
return ErrorCode::NotImplemented;
}
Result<app::services::UpdateMetadataResponse>
updateMetadata(const app::services::UpdateMetadataRequest&) override {
return ErrorCode::NotImplemented;
}
Result<std::string> resolveNameToHash(const std::string&, bool) override {
return ErrorCode::NotImplemented;
}
Result<app::services::DeleteByNameResponse>
deleteByName(const app::services::DeleteByNameRequest& request) override {
deleteRequests.push_back(request);
if (failuresRemaining > 0) {
--failuresRemaining;
return Error{ErrorCode::IOError, "simulated deletion failure"};
}
app::services::DeleteByNameResponse response;
response.count = 1;
response.deleted.push_back(
{.name = request.name, .deleted = true, .errorCode = ErrorCode::Success});
return response;
}
Result<app::services::DeleteByNameResponse>
pruneVersions(const app::services::PruneVersionsRequest&) override {
return ErrorCode::NotImplemented;
}
std::size_t failuresRemaining{0};
std::vector<app::services::DeleteByNameRequest> deleteRequests;
};
void requireReadyDatabaseState(const StateComponent& state) {
CHECK(state.readiness.databaseReady.load());
std::lock_guard<std::mutex> lk(state.readiness.recoveryMutex);
CHECK((state.readiness.databasePhase == "ready"));
}
struct PluginHostLifecycleCounters {
std::atomic<std::size_t> listCalls{0};
std::atomic<std::size_t> unloadCalls{0};
};
class CountingAbiPluginHost final : public AbiPluginHost {
public:
explicit CountingAbiPluginHost(std::shared_ptr<PluginHostLifecycleCounters> counters)
: counters_(counters) {}
std::vector<PluginDescriptor> listLoaded() const override {
counters_->listCalls.fetch_add(1, std::memory_order_relaxed);
if (counters_->unloadCalls.load(std::memory_order_relaxed) == 0) {
return {{.name = "counted_plugin"}};
}
return {};
}
Result<void> unload(const std::string&) override {
counters_->unloadCalls.fetch_add(1, std::memory_order_relaxed);
return {};
}
private:
std::shared_ptr<PluginHostLifecycleCounters> counters_;
};
fs::path writeProtectedWriterManifest(const fs::path& directory, std::string_view nodeId,
std::string_view corpusId, std::uint64_t corpusEpoch) {
auto keys = memory_sync::generateWriterKeyPair();
REQUIRE(keys.has_value());
const auto privateKey = directory / "writer-private.pem";
const auto publicKey = directory / "writer-public.pem";
REQUIRE(
ServiceManager::__test_writeProtectedP2pPrivateKey(privateKey, keys.value().privateKeyPem)
.has_value());
REQUIRE(ServiceManager::__test_writeProtectedP2pPrivateKey(publicKey, keys.value().publicKeyPem)
.has_value());
const auto manifest = directory / "writers.json";
const nlohmann::json content{
{"schema_version", 1},
{"corpus_id", corpusId},
{"corpus_epoch", corpusEpoch},
{"local_key",
{{"writer_id", nodeId},
{"key_id", "local-v1"},
{"private_key_path", privateKey.string()}}},
{"trusted_writers", nlohmann::json::array({{{"writer_id", nodeId},
{"key_id", "local-v1"},
{"public_key_path", publicKey.string()}}})}};
REQUIRE(
ServiceManager::__test_writeProtectedP2pPrivateKey(manifest, content.dump()).has_value());
return manifest;
}
void grantUntrustedKeyAccess(const fs::path& keyPath, bool writable) {
#ifdef _WIN32
BYTE worldSid[SECURITY_MAX_SID_SIZE];
DWORD worldSidBytes = sizeof(worldSid);
REQUIRE(CreateWellKnownSid(WinWorldSid, nullptr, worldSid, &worldSidBytes));
PACL currentAcl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
REQUIRE(GetNamedSecurityInfoW(const_cast<wchar_t*>(keyPath.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION, nullptr, nullptr, &currentAcl, nullptr,
&descriptor) == ERROR_SUCCESS);
EXPLICIT_ACCESSW access{};
access.grfAccessPermissions = writable ? GENERIC_WRITE : GENERIC_READ;
access.grfAccessMode = GRANT_ACCESS;
access.grfInheritance = NO_INHERITANCE;
BuildTrusteeWithSidW(&access.Trustee, worldSid);
PACL broadenedAcl = nullptr;
REQUIRE(SetEntriesInAclW(1, &access, currentAcl, &broadenedAcl) == ERROR_SUCCESS);
REQUIRE(SetNamedSecurityInfoW(const_cast<wchar_t*>(keyPath.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, broadenedAcl, nullptr) == ERROR_SUCCESS);
LocalFree(broadenedAcl);
LocalFree(descriptor);
#else
fs::permissions(keyPath, writable ? fs::perms::group_write : fs::perms::group_read,
fs::perm_options::add);
#endif
}
} // namespace
// Test fixture for ServiceManager tests
struct ServiceManagerFixture {
yams::test::ScopedEnvVar disableWatcher_{"YAMS_DISABLE_SESSION_WATCHER", std::string("1")};
DaemonConfig config_;
StateComponent state_;
DaemonLifecycleFsm lifecycleFsm_;
fs::path testDir_;
ServiceManagerFixture() {
// Create isolated test directory
testDir_ =
fs::temp_directory_path() /
("sm_test_" + std::to_string(std::hash<std::thread::id>{}(std::this_thread::get_id())) +
"_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(testDir_);
// Setup basic config
config_.dataDir = testDir_ / "data";
config_.socketPath = testDir_ / "daemon.sock";
config_.pidFile = testDir_ / "daemon.pid";
config_.logFile = testDir_ / "daemon.log";
fs::create_directories(config_.dataDir);
}
~ServiceManagerFixture() {
// Cleanup test directory
if (fs::exists(testDir_)) {
std::error_code ec;
fs::remove_all(testDir_, ec);
}
}
};
struct ScopedCurrentPath {
fs::path original_;
explicit ScopedCurrentPath(const fs::path& target) : original_(fs::current_path()) {
fs::current_path(target);
}
~ScopedCurrentPath() {
std::error_code ec;
fs::current_path(original_, ec);
}
};
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager data-directory snapshots survive publication",
"[daemon][service_manager][data-dir-snapshot]") {
yams::test::SpdlogLevelGuard logGuard;
spdlog::set_level(spdlog::level::warn);
ServiceManager manager(config_, state_, lifecycleFsm_);
// Holding a const reference must extend the lifetime of an owning snapshot,
// not expose the mutable path that early metrics and storage fallback publish.
const auto& beforePublication = manager.getResolvedDataDir();
REQUIRE(beforePublication.empty());
const auto primary = testDir_ / "primary-data";
manager.testingSetResolvedDataDir(primary);
CHECK(beforePublication.empty());
const auto& primarySnapshot = manager.getResolvedDataDir();
REQUIRE(primarySnapshot == primary);
const auto fallback = testDir_ / "fallback-data";
manager.testingSetResolvedDataDir(fallback);
CHECK(primarySnapshot == primary);
CHECK(manager.getResolvedDataDir() == fallback);
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager snapshots remain whole during publication",
"[daemon][service_manager][data-dir-snapshot][concurrency]") {
yams::test::SpdlogLevelGuard logGuard;
spdlog::set_level(spdlog::level::warn);
ServiceManager manager(config_, state_, lifecycleFsm_);
// These are path values only: no filesystem entry with a long component is created.
const auto primary = testDir_ / std::string(512, 'a');
const auto fallback = testDir_ / std::string(512, 'b');
manager.testingSetResolvedDataDir(primary);
const auto& initialSnapshot = manager.getResolvedDataDir();
std::promise<void> begin;
const auto ready = begin.get_future().share();
yams::compat::jthread writer([&] {
ready.wait();
for (unsigned i = 0; i < 256; ++i) {
manager.testingSetResolvedDataDir(i % 2 == 0 ? primary : fallback);
}
});
begin.set_value();
unsigned invalidSnapshots = 0;
for (unsigned i = 0; i < 256; ++i) {
const auto snapshot = manager.getResolvedDataDir();
if (snapshot != primary && snapshot != fallback) {
++invalidSnapshots;
}
}
writer.join();
CHECK(invalidSnapshots == 0);
CHECK(initialSnapshot == primary);
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager construction succeeds",
"[daemon][service_manager]") {
REQUIRE_NOTHROW(ServiceManager(config_, state_, lifecycleFsm_));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager marks the vector DB ready once its first vectors commit",
"[daemon][components][service-manager][catch2][vector-readiness]") {
ServiceManager manager(config_, state_, lifecycleFsm_);
state_.readiness.vectorDbReady = false;
// Before the vector DB was opened, a commit callback proves nothing about it.
state_.readiness.vectorDbInitAttempted = false;
manager.testingMarkVectorDbReadyAfterCommit({"hash-a"});
CHECK_FALSE(state_.readiness.vectorDbReady.load());
// An initialized DB that started empty becomes ready on the first non-empty commit.
state_.readiness.vectorDbInitAttempted = true;
manager.testingMarkVectorDbReadyAfterCommit({});
CHECK_FALSE(state_.readiness.vectorDbReady.load());
manager.testingMarkVectorDbReadyAfterCommit({"hash-a", "hash-b"});
CHECK(state_.readiness.vectorDbReady.load());
}
TEST_CASE("Async initialization completion wait survives caller timeout",
"[daemon][service_manager][async-completion]") {
AsyncInitOrchestrator init;
REQUIRE(init.tryStart());
const auto token = init.getStopToken();
// A caller may time out before co_spawn installs its future.
CHECK_FALSE(init.waitForCompletion(std::chrono::milliseconds(0)));
CHECK_FALSE(token.stop_requested());
std::promise<void> completion;
init.setFuture(completion.get_future());
CHECK_FALSE(init.waitForCompletion(std::chrono::milliseconds(0)));
CHECK_FALSE(token.stop_requested());
// The future remains owned by the orchestrator, not the timed-out caller.
completion.set_value();
CHECK(init.waitForCompletion(std::chrono::milliseconds(0)));
CHECK_FALSE(token.stop_requested());
CHECK(init.requestStopAndWait(std::chrono::milliseconds(0)));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager getName returns correct component name",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
REQUIRE((std::string(sm.getName()) == "ServiceManager"));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager service accessors after construction",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
// PBI-057: Vector DB initialization is deferred to async phase
REQUIRE((sm.getVectorDatabase() == nullptr));
// Other services are initialized during initialize(), not in constructor
REQUIRE((sm.getContentStore() == nullptr));
REQUIRE((sm.getMetadataRepo() == nullptr));
REQUIRE((sm.getModelProvider() == nullptr));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager multiple construction is idempotent",
"[daemon][service_manager]") {
// Create two instances sequentially - should not throw
ServiceManager sm1(config_, state_, lifecycleFsm_);
ServiceManager sm2(config_, state_, lifecycleFsm_);
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Concurrent embedded ServiceManager preserves active tuning lifecycle",
"[daemon][service_manager][config][tuning][lifecycle]") {
yams::test::ScopedEnvVar timeout{"YAMS_IPC_TIMEOUT_MS", std::nullopt};
TuneAdvisor::setIpcTimeoutMs(4321);
config_.tuning.tuneAdvisorOverridesResolved = true;
ServiceManager owner(config_, state_, lifecycleFsm_);
REQUIRE((owner.getRuntimeTuningStatus().at("ipc.timeout_ms") == "4321"));
auto directConfig = config_;
directConfig.tuning.tuneAdvisorOverridesResolved = false;
ServiceManager embedded(directConfig, state_, lifecycleFsm_);
CHECK((TuneAdvisor::ipcTimeoutMs() == 4321u));
CHECK((owner.getRuntimeTuningStatus().at("ipc.timeout_ms") == "4321"));
CHECK((embedded.getRuntimeTuningStatus().at("ipc.timeout_ms") == "4321"));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager construction with missing data directory",
"[daemon][service_manager]") {
fs::remove_all(config_.dataDir);
REQUIRE_NOTHROW(ServiceManager(config_, state_, lifecycleFsm_));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager initialize reports data directory creation failures",
"[daemon][service_manager][startup][error]") {
fs::remove_all(config_.dataDir);
{
std::ofstream blocker(config_.dataDir);
REQUIRE(blocker.good());
blocker << "not a directory";
}
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto result = sm.initialize();
REQUIRE_FALSE(result);
CHECK((result.error().code == ErrorCode::IOError));
CHECK((result.error().message.find("Failed to create data directory") != std::string::npos));
CHECK((result.error().message.find(config_.dataDir.string()) != std::string::npos));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Task record publication and reads validate immutable revision identity",
"[daemon][service_manager][task-record]") {
storage::BackendConfig backendConfig;
backendConfig.localPath = config_.dataDir / "task-record-store";
auto backend = std::make_unique<storage::FilesystemBackend>();
REQUIRE(backend->initialize(backendConfig).has_value());
ServiceManager manager(config_, state_, lifecycleFsm_);
manager.testingSetMemorySyncService(std::make_unique<memory_sync::MemorySyncService>(
std::move(backend), memory_sync::MemorySyncConfig{"A", 50}));
auto* sync = manager.testingMemorySyncService();
const std::string taskId = "123e4567-e89b-42d3-a456-426614174000";
nlohmann::json record = {
{"schema", "yams.task-record/v1"},
{"task_id", taskId},
{"kind", "claim"},
{"owner", "agent-a"},
{"source_uri", "repo:src/search"},
{"source_revision", "git:9883a438"},
{"recorded_at_ms", 2000},
{"source_timestamp_ms", 1000},
{"supersedes", nlohmann::json::array()},
{"ownership_semantics", "record_only"},
{"body", "Investigating retrieval"},
};
const auto value = record.dump();
const auto hash = crypto::SHA256Hasher::hash(std::as_bytes(std::span(value)));
const auto key = "task-record/" + taskId + "/" + hash;
const auto published = manager.publishMemorySync(key, value);
CAPTURE(backendConfig.localPath.string(), key.size());
INFO((published ? "task record published" : published.error().message));
REQUIRE(published.has_value());
REQUIRE(sync->syncOnce().has_value());
const auto hydrated = manager.readMemorySyncCached(key);
REQUIRE(hydrated.has_value());
CHECK(hydrated.value() == value);
record["body"] = "changed revision";
CHECK_FALSE(manager.publishMemorySync(key, record.dump()).has_value());
record["ownership_semantics"] = "acquired";
const auto acquired = record.dump();
const auto acquiredHash = crypto::SHA256Hasher::hash(std::as_bytes(std::span(acquired)));
CHECK_FALSE(manager.publishMemorySync("task-record/" + taskId + "/" + acquiredHash, acquired)
.has_value());
// A remote/lower-level writer cannot bypass the typed read contract.
const auto namespaced = memory_sync::userLogicalKey(key);
REQUIRE(namespaced.has_value());
REQUIRE(sync->publish(namespaced.value(), std::as_bytes(std::span(acquired))).has_value());
REQUIRE(sync->syncOnce().has_value());
CHECK_FALSE(manager.readMemorySyncCached(key).has_value());
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager rejects replication of a personal corpus",
"[daemon][service_manager][sharing][security][config]") {
config_.memorySync.enabled = true;
config_.memorySync.nodeId = "123e4567-e89b-42d3-a456-42661417400a";
config_.memorySync.corpusId = "personal-corpus";
config_.memorySync.corpusEpoch = 1;
config_.memorySync.path = (config_.dataDir / "forbidden-sync-store").string();
SECTION("direct") {
config_.memorySync.transport = "direct";
}
SECTION("shared store") {
config_.memorySync.transport = "shared-store";
}
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().message.find("corpus_scope=shared") != std::string::npos);
CHECK_FALSE(fs::exists(config_.memorySync.path));
CHECK_FALSE(fs::exists(config_.dataDir / "p2p" / "op-store"));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager rejects direct P2P without usable writer authentication",
"[daemon][service_manager][p2p][security][config]") {
config_.memorySync.enabled = true;
config_.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
config_.memorySync.transport = "direct";
config_.memorySync.nodeId = "123e4567-e89b-42d3-a456-42661417400a";
config_.memorySync.corpusId = "missing-writer-auth";
config_.memorySync.corpusEpoch = 1;
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().code == ErrorCode::InvalidArgument);
CHECK(initialized.error().message.find("writer_auth_required=true") != std::string::npos);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager rejects provenance-unknown direct operation stores",
"[daemon][service_manager][p2p][security][migration]") {
const std::string nodeId = "123e4567-e89b-42d3-a456-42661417400a";
const std::string corpusId = "legacy-direct-store";
const auto manifest = writeProtectedWriterManifest(testDir_, nodeId, corpusId, 2);
const auto legacyObject = config_.dataDir / "p2p" / "op-store" / "legacy" / "record.json";
fs::create_directories(legacyObject.parent_path());
std::ofstream(legacyObject) << "unsigned-history";
config_.memorySync.enabled = true;
config_.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
config_.memorySync.transport = "direct";
config_.memorySync.nodeId = nodeId;
config_.memorySync.corpusId = corpusId;
config_.memorySync.corpusEpoch = 2;
config_.memorySync.writerAuthRequired = true;
config_.memorySync.writerAuthManifestPath = manifest.string();
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().code == ErrorCode::InvalidState);
CHECK(initialized.error().message.find("provenance-unknown") != std::string::npos);
CHECK(fs::exists(legacyObject));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager rejects symbolic-link P2P identity keys",
"[daemon][service_manager][p2p][security]") {
#ifdef _WIN32
SKIP("symbolic-link permission semantics differ on Windows");
#else
auto generated = memory_sync::generateWriterKeyPair();
REQUIRE(generated.has_value());
const auto victim = testDir_ / "victim.pem";
{
std::ofstream output(victim, std::ios::binary | std::ios::trunc);
REQUIRE(output.good());
output << generated.value().privateKeyPem;
}
fs::permissions(victim, fs::perms::owner_read | fs::perms::owner_write,
fs::perm_options::replace);
const auto publicKey = testDir_ / "identity.pub.pem";
{
std::ofstream output(publicKey, std::ios::binary | std::ios::trunc);
REQUIRE(output.good());
output << generated.value().publicKeyPem;
}
// Trust files must not be group/other-writable; set it explicitly so the result does not
// depend on the process umask (002 on Debian-style user-private-group systems).
constexpr auto kTrustFilePerms = fs::perms::owner_read | fs::perms::owner_write |
fs::perms::group_read | fs::perms::others_read;
fs::permissions(publicKey, kTrustFilePerms, fs::perm_options::replace);
const auto manifest = testDir_ / "writers.json";
{
nlohmann::json content{
{"schema_version", 1},
{"corpus_id", "identity-security-test"},
{"corpus_epoch", 1},
{"local_key",
{{"writer_id", "123e4567-e89b-42d3-a456-42661417400a"},
{"key_id", "local-v1"},
{"private_key_path", victim.string()}}},
{"trusted_writers",
nlohmann::json::array({{{"writer_id", "123e4567-e89b-42d3-a456-42661417400a"},
{"key_id", "local-v1"},
{"public_key_path", publicKey.string()}}})}};
std::ofstream output(manifest, std::ios::binary | std::ios::trunc);
REQUIRE(output.good());
output << content.dump();
}
fs::permissions(manifest, kTrustFilePerms, fs::perm_options::replace);
const auto identity = testDir_ / "identity.pem";
fs::create_symlink(victim, identity);
config_.memorySync.enabled = true;
config_.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
config_.memorySync.transport = "direct";
config_.memorySync.nodeId = "123e4567-e89b-42d3-a456-42661417400a";
config_.memorySync.corpusId = "identity-security-test";
config_.memorySync.corpusEpoch = 1;
config_.memorySync.identityKeyPath = identity.string();
config_.memorySync.writerAuthRequired = true;
config_.memorySync.writerAuthManifestPath = manifest.string();
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().code == ErrorCode::Unauthorized);
CHECK(initialized.error().message.find("symbolic link") != std::string::npos);
CHECK(fs::is_symlink(fs::symlink_status(identity)));
#endif
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager rejects broadly accessible direct writer private keys",
"[daemon][service_manager][p2p][security][platform]") {
const std::string nodeId = "123e4567-e89b-42d3-a456-42661417400a";
const std::string corpusId = "writer-key-security";
const auto manifest = writeProtectedWriterManifest(testDir_, nodeId, corpusId, 1);
const auto identity = testDir_ / "separate-identity.pem";
REQUIRE(ServiceManager::__test_loadOrCreateP2pPrivateKey(identity).has_value());
grantUntrustedKeyAccess(testDir_ / "writer-private.pem", false);
config_.memorySync.enabled = true;
config_.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
config_.memorySync.transport = "direct";
config_.memorySync.nodeId = nodeId;
config_.memorySync.corpusId = corpusId;
config_.memorySync.corpusEpoch = 1;
config_.memorySync.identityKeyPath = identity.string();
config_.memorySync.writerAuthRequired = true;
config_.memorySync.writerAuthManifestPath = manifest.string();
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().code == ErrorCode::Unauthorized);
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager rejects writable direct writer trust files",
"[daemon][service_manager][p2p][security][platform]") {
const std::string nodeId = "123e4567-e89b-42d3-a456-42661417400a";
const std::string corpusId = "writer-trust-security";
const auto manifest = writeProtectedWriterManifest(testDir_, nodeId, corpusId, 1);
const auto identity = testDir_ / "trust-test-identity.pem";
REQUIRE(ServiceManager::__test_loadOrCreateP2pPrivateKey(identity).has_value());
SECTION("writable manifest") {
grantUntrustedKeyAccess(manifest, true);
}
SECTION("writable public key") {
grantUntrustedKeyAccess(testDir_ / "writer-public.pem", true);
}
config_.memorySync.enabled = true;
config_.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
config_.memorySync.transport = "direct";
config_.memorySync.nodeId = nodeId;
config_.memorySync.corpusId = corpusId;
config_.memorySync.corpusEpoch = 1;
config_.memorySync.identityKeyPath = identity.string();
config_.memorySync.writerAuthRequired = true;
config_.memorySync.writerAuthManifestPath = manifest.string();
ServiceManager manager(config_, state_, lifecycleFsm_);
REQUIRE(manager.initialize().has_value());
boost::asio::io_context io;
compat::stop_source stopSource;
auto future = boost::asio::co_spawn(
io, manager.initializeAsyncAwaitable(stopSource.get_token()), boost::asio::use_future);
io.run();
const auto initialized = future.get();
REQUIRE_FALSE(initialized.has_value());
CHECK(initialized.error().code == ErrorCode::Unauthorized);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager creates owner-only P2P identity keys and rejects broad access",
"[daemon][service_manager][p2p][security][platform]") {
const auto identity = testDir_ / "generated-identity.pem";
auto created = ServiceManager::__test_loadOrCreateP2pPrivateKey(identity);
REQUIRE(created.has_value());
REQUIRE_FALSE(created.value().empty());
REQUIRE(ServiceManager::__test_loadOrCreateP2pPrivateKey(identity).has_value());
grantUntrustedKeyAccess(identity, false);
auto rejected = ServiceManager::__test_loadOrCreateP2pPrivateKey(identity);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().code == ErrorCode::Unauthorized);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager rejects inherited Windows P2P identity ACLs",
"[daemon][service_manager][p2p][security][platform]") {
#ifndef _WIN32
SKIP("Windows ACL-specific coverage");
#else
const auto identity = testDir_ / "inherited-identity.pem";
REQUIRE(ServiceManager::__test_loadOrCreateP2pPrivateKey(identity).has_value());
PACL currentAcl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
REQUIRE(GetNamedSecurityInfoW(const_cast<wchar_t*>(identity.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION, nullptr, nullptr, &currentAcl, nullptr,
&descriptor) == ERROR_SUCCESS);
REQUIRE(SetNamedSecurityInfoW(const_cast<wchar_t*>(identity.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
nullptr, nullptr, currentAcl, nullptr) == ERROR_SUCCESS);
LocalFree(descriptor);
auto rejected = ServiceManager::__test_loadOrCreateP2pPrivateKey(identity);
REQUIRE_FALSE(rejected.has_value());
CHECK(rejected.error().code == ErrorCode::Unauthorized);
#endif
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager pool configuration failures propagate through Result",
"[daemon][service_manager][startup][error]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto result = sm.__test_initializeWithPoolConfiguration(
[] { throw std::runtime_error("forced pool configuration failure"); });
REQUIRE_FALSE(result);
CHECK((result.error().code == ErrorCode::InternalError));
CHECK((result.error().message.find("forced pool configuration failure") != std::string::npos));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager destructor handles cleanup",
"[daemon][service_manager]") {
auto sm = std::make_unique<ServiceManager>(config_, state_, lifecycleFsm_);
REQUIRE_NOTHROW(sm.reset());
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager delegates shared plugin host shutdown once",
"[daemon][service_manager][plugin][ownership][shutdown]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
auto counters = std::make_shared<PluginHostLifecycleCounters>();
sm.__test_setAbiHost(std::make_unique<CountingAbiPluginHost>(counters));
sm.shutdown();
CHECK((counters->listCalls.load(std::memory_order_relaxed) == 1));
CHECK((counters->unloadCalls.load(std::memory_order_relaxed) == 1));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager getConfig returns configuration",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto& cfg = sm.getConfig();
REQUIRE((cfg.dataDir == config_.dataDir));
REQUIRE((cfg.socketPath == config_.socketPath));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager snapshots search rebuild compatibility policy once",
"[daemon][service_manager][config][search]") {
yams::test::ScopedEnvVar disableRebuilds{"YAMS_DISABLE_SEARCH_REBUILDS", std::string{"1"}};
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto& policy = sm.getConfig().searchMaintenance;
REQUIRE(policy.automaticRebuildsEnabled.has_value());
CHECK_FALSE(*policy.automaticRebuildsEnabled);
CHECK((policy.automaticRebuildsSource ==
"compatibility-environment:YAMS_DISABLE_SEARCH_REBUILDS"));
REQUIRE((sm.getSearchComponent() != nullptr));
CHECK_FALSE(sm.getSearchComponent()->getConfig().automaticRebuildsEnabled);
disableRebuilds.set("0");
CHECK_FALSE(*sm.getConfig().searchMaintenance.automaticRebuildsEnabled);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Typed search rebuild policy outranks compatibility environment",
"[daemon][service_manager][config][search]") {
yams::test::ScopedEnvVar disableRebuilds{"YAMS_DISABLE_SEARCH_REBUILDS", std::string{"1"}};
config_.searchMaintenance.automaticRebuildsEnabled = true;
config_.searchMaintenance.automaticRebuildsSource = "typed:test";
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto& policy = sm.getConfig().searchMaintenance;
REQUIRE(policy.automaticRebuildsEnabled.has_value());
CHECK(*policy.automaticRebuildsEnabled);
CHECK((policy.automaticRebuildsSource == "typed:test"));
REQUIRE((sm.getSearchComponent() != nullptr));
CHECK(sm.getSearchComponent()->getConfig().automaticRebuildsEnabled);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager snapshots runtime tuning provenance across live refresh",
"[daemon][service_manager][config][tuning][snapshot]") {
yams::test::ScopedEnvVar timeout{"YAMS_IPC_TIMEOUT_MS", std::string{"4321"}};
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto snapshot = sm.getTuningConfig();
REQUIRE((sm.getRuntimeTuningStatus().at("ipc.timeout_ms.source") ==
"compatibility-environment:YAMS_IPC_TIMEOUT_MS"));
timeout.unset();
sm.setTuningConfig(snapshot);
CHECK((sm.getRuntimeTuningStatus().at("ipc.timeout_ms.source") ==
"compatibility-environment:YAMS_IPC_TIMEOUT_MS"));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Live tuning refresh reports preserved construction-time capacity",
"[daemon][service_manager][config][tuning][physical-state]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
config_.tuning.postIngestCapacity = 111;
config_.tuning.provenance["tuning.post_ingest_capacity"] = "config:tuning.post_ingest_capacity";
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto beforeInitialization = sm->getTuningConfig();
beforeInitialization.postIngestCapacity = 222;
beforeInitialization.provenance["tuning.post_ingest_capacity"] =
"config:tuning.post_ingest_capacity";
sm->setTuningConfig(beforeInitialization);
REQUIRE(sm->initialize());
sm->startAsyncInit();
const auto ready = sm->waitForServiceManagerTerminalState(30);
REQUIRE((ready.state == ServiceManagerState::Ready));
REQUIRE(sm->getPostIngestQueue() != nullptr);
CHECK((sm->getPostIngestQueue()->capacity() == 222u));
TuningConfig refreshed;
refreshed.tuneAdvisorOverridesResolved = true;
sm->setTuningConfig(refreshed);
const auto effective = sm->getTuningConfig();
CHECK((effective.postIngestCapacity == 222u));
CHECK((effective.provenance.at("tuning.post_ingest_capacity") ==
"runtime:construction-time-post-ingest-channel"));
const auto status = sm->getRuntimeTuningStatus();
CHECK((status.at("post_ingest.capacity") == "222"));
CHECK((status.at("post_ingest.capacity.source") ==
"runtime:construction-time-post-ingest-channel"));
sm->shutdown();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Runtime tuning status publishes values from one configured generation",
"[daemon][service_manager][config][tuning][snapshot]") {
config_.tuning.tuneAdvisorOverridesResolved = true;
TuneAdvisor::setIpcTimeoutMs(1001);
TuneAdvisor::setMemoryWarningThreshold(0.61);
ServiceManager sm(config_, state_, lifecycleFsm_);
std::atomic<bool> stop{false};
std::thread writer([&] {
bool alternate = false;
while (!stop.load(std::memory_order_acquire)) {
[[maybe_unused]] auto update = TuneAdvisor::beginConfiguredOverrideUpdate();
TuneAdvisor::setIpcTimeoutMs(alternate ? 1001 : 2002);
TuneAdvisor::setMemoryWarningThreshold(alternate ? 0.61 : 0.82);
alternate = !alternate;
}
});
for (int i = 0; i < 500; ++i) {
sm.setTuningConfig(sm.getTuningConfig());
const auto status = sm.getRuntimeTuningStatus();
const auto timeout = status.at("ipc.timeout_ms");
const auto warning = status.at("resource.memory_warning_threshold");
CHECK(((timeout == "1001" && warning == "0.610000") ||
(timeout == "2002" && warning == "0.820000")));
}
stop.store(true, std::memory_order_release);
writer.join();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"Direct embedded ServiceManager starts a fresh tuning lifecycle",
"[daemon][service_manager][config][tuning][lifecycle]") {
yams::test::ScopedEnvVar timeout{"YAMS_IPC_TIMEOUT_MS", std::nullopt};
TuneAdvisor::setIpcTimeoutMs(4321);
REQUIRE_FALSE(config_.tuning.tuneAdvisorOverridesResolved);
ServiceManager sm(config_, state_, lifecycleFsm_);
CHECK(sm.getTuningConfig().tuneAdvisorOverridesResolved);
CHECK((sm.getRuntimeTuningStatus().at("ipc.timeout_ms") == "15000"));
}
TEST_CASE("ServiceManager session watcher honors only the test disable gate",
"[daemon][service_manager][session_watch]") {
CHECK(ServiceManager::__test_shouldStartSessionWatcher(""));
CHECK(ServiceManager::__test_shouldStartSessionWatcher("0"));
CHECK_FALSE(ServiceManager::__test_shouldStartSessionWatcher("true"));
}
TEST_CASE("ServiceManager session watcher uses configured intervals without busy polling",
"[daemon][service_manager][session_watch]") {
using namespace std::chrono_literals;
CHECK((ServiceManager::__test_sessionWatcherDelay(false, 0) == 2s));
CHECK((ServiceManager::__test_sessionWatcherDelay(true, 250) == 250ms));
CHECK((ServiceManager::__test_sessionWatcherDelay(true, 20) == 100ms));
}
TEST_CASE("ServiceManager session watcher refreshes every retrieval index",
"[daemon][service_manager][session_watch]") {
auto request = ServiceManager::__test_makeSessionWatchRequest("coding", "/workspace/yams",
{"src/search/search_engine.cpp"});
CHECK((request.directoryPath == "/workspace/yams"));
CHECK((request.includePatterns == std::vector<std::string>{"src/search/search_engine.cpp"}));
CHECK(request.recursive);
CHECK((request.sessionId == "coding"));
CHECK_FALSE(request.noEmbeddings);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager session watcher retries failed indexing and deletion",
"[daemon][service_manager][session_watch]") {
ServiceManager serviceManager(config_, state_, lifecycleFsm_);
SessionWatchIndexingService indexingService;
SessionWatchDocumentService documentService;
const auto watchedDirectory = testDir_ / "watched";
const auto watchedFile = watchedDirectory / "changed.cpp";
fs::create_directories(watchedDirectory);
{
std::ofstream output(watchedFile);
REQUIRE(output.good());
output << "int changed = 1;\n";
}
indexingService.failuresRemaining = 1;
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
REQUIRE((indexingService.requests.size() == 1));
CHECK((indexingService.requests.front().includePatterns ==
std::vector<std::string>{"changed.cpp"}));
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((indexingService.requests.size() == 2));
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((indexingService.requests.size() == 2));
REQUIRE(fs::remove(watchedFile));
documentService.failuresRemaining = 1;
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
REQUIRE((documentService.deleteRequests.size() == 1));
CHECK((documentService.deleteRequests.front().name == watchedFile.string()));
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((documentService.deleteRequests.size() == 2));
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((documentService.deleteRequests.size() == 2));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager session watcher keeps progressing past a failing path",
"[daemon][service_manager][session_watch]") {
ServiceManager serviceManager(config_, state_, lifecycleFsm_);
SessionWatchIndexingService indexingService;
SessionWatchDocumentService documentService;
const auto watchedDirectory = testDir_ / "watched_progress";
fs::create_directories(watchedDirectory);
const auto writeFile = [&](const fs::path& path, std::string_view text) {
std::ofstream output(path);
REQUIRE(output.good());
output << text;
};
writeFile(watchedDirectory / "kept.cpp", "int kept = 1;\n");
writeFile(watchedDirectory / "gone.cpp", "int gone = 1;\n");
REQUIRE(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
REQUIRE((indexingService.requests.size() == 1));
SECTION("a removal that keeps failing does not re-index unrelated files") {
REQUIRE(fs::remove(watchedDirectory / "gone.cpp"));
documentService.failuresRemaining = 1000;
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(
indexingService, &documentService, "coding", watchedDirectory));
REQUIRE_FALSE(documentService.deleteRequests.empty());
// The path no longer exists, so every document stored at it is stale.
CHECK(documentService.deleteRequests.back().force);
writeFile(watchedDirectory / "added.cpp", "int added = 1;\n");
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(
indexingService, &documentService, "coding", watchedDirectory));
REQUIRE((indexingService.requests.size() == 2));
CHECK((indexingService.requests.back().includePatterns ==
std::vector<std::string>{"added.cpp"}));
// Before the fix the stale snapshot made added.cpp "changed" on every pass.
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(
indexingService, &documentService, "coding", watchedDirectory));
CHECK((indexingService.requests.size() == 2));
documentService.failuresRemaining = 0;
const auto deletesBefore = documentService.deleteRequests.size();
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((documentService.deleteRequests.size() == deletesBefore + 1));
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
CHECK((documentService.deleteRequests.size() == deletesBefore + 1));
}
SECTION("only the file that failed to index is retried") {
writeFile(watchedDirectory / "good.cpp", "int good = 1;\n");
writeFile(watchedDirectory / "bad.cpp", "int bad = 1;\n");
indexingService.failingFiles = {"bad.cpp"};
CHECK_FALSE(serviceManager.__test_scanSessionWatchDirectory(
indexingService, &documentService, "coding", watchedDirectory));
REQUIRE((indexingService.requests.size() == 2));
indexingService.failingFiles.clear();
CHECK(serviceManager.__test_scanSessionWatchDirectory(indexingService, &documentService,
"coding", watchedDirectory));
REQUIRE((indexingService.requests.size() == 3));
CHECK((indexingService.requests.back().includePatterns ==
std::vector<std::string>{"bad.cpp"}));
}
}
TEST_CASE("ServiceManager topology readiness follows artifact freshness",
"[daemon][service_manager][topology]") {
yams::search::IndexFreshnessSnapshot snapshot;
snapshot.topologyStatusKnown = true;
snapshot.topologyEpoch = 42;
snapshot.topologyArtifactsFresh = true;
CHECK(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.topologyEpoch = 0;
CHECK_FALSE(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.topologyEpoch = 42;
snapshot.topologyArtifactsFresh = false;
CHECK_FALSE(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.topologyArtifactsFresh = true;
snapshot.topologyRebuildRunning = true;
CHECK_FALSE(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.topologyRebuildRunning = false;
snapshot.postIngestQueued = 1;
CHECK_FALSE(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.postIngestQueued = 0;
snapshot.topologyDirtyDocuments = 1;
CHECK_FALSE(ServiceManager::isTopologyRoutingReady(snapshot));
snapshot.topologyDirtyDocuments = 0;
snapshot.awaitingDrain = true;
snapshot.lexicalReady = false;
snapshot.kgReady = false;
CHECK(ServiceManager::isTopologyRoutingReady(snapshot));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager PostIngestQueue accessor before init",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
// May be null before initialization
auto piq = sm.getPostIngestQueue();
(void)piq;
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager tuning config getter doesn't crash",
"[daemon][service_manager]") {
config_.tuning.ingestStoreBatchSize = 19;
ServiceManager sm(config_, state_, lifecycleFsm_);
const auto& tuning = sm.getTuningConfig();
CHECK((tuning.ingestStoreBatchSize == 19));
CHECK((sm.getIngestStoreBatchSize() == 19));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager set tuning config doesn't crash",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
TuningConfig tc;
tc.postIngestCapacity = 1000;
tc.postIngestThreadsMin = 2;
tc.postIngestThreadsMax = 4;
tc.ingestStoreBatchSize = 23;
REQUIRE_NOTHROW(sm.setTuningConfig(tc));
CHECK((sm.getIngestStoreBatchSize() == 23));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager getWorkerQueueDepth doesn't crash",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
auto depth = sm.getWorkerQueueDepth();
(void)depth;
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager enqueuePostIngest doesn't crash",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
REQUIRE_NOTHROW(sm.enqueuePostIngest("test_hash", "text/plain"));
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager search engine snapshot doesn't crash",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
auto snapshot = sm.getSearchEngineFsmSnapshot();
(void)snapshot.buildReason;
(void)snapshot.vectorEnabled;
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager cached search engine access doesn't crash",
"[daemon][service_manager]") {
ServiceManager sm(config_, state_, lifecycleFsm_);
auto engine = sm.getCachedSearchEngine();
(void)engine; // May be null, that's OK
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager memory cleanup verification",
"[daemon][service_manager][.slow]") {
// Note: This test rapidly creates/destroys ServiceManagers which may be flaky
// on some platforms due to timing-sensitive cleanup
for (int i = 0; i < 3; ++i) {
auto sm = std::make_unique<ServiceManager>(config_, state_, lifecycleFsm_);
sm.reset();
}
SUCCEED();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager restart creates fresh io_context (PBI-066-38)",
"[daemon][service_manager]") {
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
// First init
auto r1 = sm->initialize();
REQUIRE(r1);
// Stop services
sm->shutdown();
// Second init should succeed and not throw bad executor
auto r2 = sm->initialize();
REQUIRE(r2);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager initializes WAL under configured data directory",
"[daemon][service_manager][wal]") {
const auto runRoot = testDir_ / "cwd";
fs::create_directories(runRoot);
ScopedCurrentPath cwdGuard(runRoot);
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
const auto expectedWalDir = config_.dataDir / "wal";
REQUIRE(fs::exists(expectedWalDir));
REQUIRE(fs::is_directory(expectedWalDir));
const auto misplacedWalDir = runRoot / "wal";
CHECK_FALSE(fs::exists(misplacedWalDir));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager async init starts only once per initialize cycle",
"[daemon][service_manager][startup][async_init]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
std::promise<void> firstBarrierPromise;
auto firstBarrierFuture = firstBarrierPromise.get_future();
std::atomic<bool> firstBarrierSet{false};
sm->startAsyncInit(&firstBarrierPromise, &firstBarrierSet);
REQUIRE((firstBarrierFuture.wait_for(std::chrono::seconds(2)) == std::future_status::ready));
CHECK(firstBarrierSet.load(std::memory_order_acquire));
std::promise<void> secondBarrierPromise;
auto secondBarrierFuture = secondBarrierPromise.get_future();
std::atomic<bool> secondBarrierSet{false};
sm->startAsyncInit(&secondBarrierPromise, &secondBarrierSet);
CHECK((secondBarrierFuture.wait_for(std::chrono::milliseconds(200)) ==
std::future_status::timeout));
CHECK_FALSE(secondBarrierSet.load(std::memory_order_acquire));
const auto snapshot = sm->waitForServiceManagerTerminalState(30);
REQUIRE((snapshot.state == ServiceManagerState::Ready));
requireReadyDatabaseState(state_);
sm->shutdown();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager consumes clean shutdown proof and republishes it on shutdown",
"[daemon][service_manager][startup][integrity_stamp]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
const auto dbPath = metadataDbPath(config_);
seedMetadataDb(dbPath, "clean_restart");
const auto initialStamp = publishDbCleanShutdownStamp(dbPath);
const std::string initialStampInfo =
initialStamp ? "clean stamp published" : initialStamp.error().message;
INFO(initialStampInfo);
REQUIRE(initialStamp);
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
REQUIRE(sm->initialize());
sm->startAsyncInit();
const auto ready = sm->waitForServiceManagerTerminalState(30);
REQUIRE((ready.state == ServiceManagerState::Ready));
CHECK(state_.readiness.databaseIntegrityFastPath.load(std::memory_order_acquire));
sm->shutdown();
const auto nextStartup = consumeDbCleanShutdownStamp(dbPath);
CHECK(nextStartup.trustedCleanShutdown);
CHECK(nextStartup.invalidationPersisted);
}
namespace {
std::atomic<std::int64_t> gInFlightQueryDeadlineNs{0};
void keepRunningUntilDeadline(sqlite3_context* context, int, sqlite3_value**) {
const auto nowNs = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
sqlite3_result_int(context, nowNs < gInFlightQueryDeadlineNs.load() ? 1 : 0);
}
} // namespace
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager shutdown interrupts an in-flight metadata query and keeps the "
"clean-shutdown stamp",
"[daemon][service_manager][shutdown][integrity_stamp][cancellation]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
config_.enableAutoRepair = false;
const auto dbPath = metadataDbPath(config_);
seedMetadataDb(dbPath, "inflight_handler");
REQUIRE(publishDbCleanShutdownStamp(dbPath));
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
REQUIRE(sm->initialize());
sm->startAsyncInit();
const auto ready = sm->waitForServiceManagerTerminalState(30);
REQUIRE((ready.state == ServiceManagerState::Ready));
auto readPool = sm->getReadConnectionPool();
REQUIRE((readPool != nullptr));
// A request handler pinned on a WorkCoordinator worker inside one long SQLite statement, the
// shape seen when a `yams list` over a large corpus was in flight during `daemon restart`.
// The statement would run well past both Phase 5 join windows unless shutdown interrupts it.
constexpr auto kStatementBudget = std::chrono::seconds(20);
gInFlightQueryDeadlineNs.store(
std::chrono::duration_cast<std::chrono::nanoseconds>(
(std::chrono::steady_clock::now() + kStatementBudget).time_since_epoch())
.count());
std::promise<void> started;
auto startedFuture = started.get_future();
std::promise<bool> finished;
auto finishedFuture = finished.get_future();
boost::asio::post(sm->getWorkerExecutor(), [readPool, &started, &finished]() {
WorkCoordinator::JobScope job("test.inflight_metadata_query");
auto conn = readPool->acquire(std::chrono::seconds(5));
if (!conn) {
started.set_value();
finished.set_value(false);
return;
}
auto& db = **conn.value();
sqlite3_create_function(db.rawHandle(), "yams_test_keep_running", 0, SQLITE_UTF8, nullptr,
keepRunningUntilDeadline, nullptr, nullptr);
started.set_value();
const auto result = db.execute("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 "
"FROM c WHERE yams_test_keep_running()) SELECT count(*) "
"FROM c");
finished.set_value(!result.has_value());
});
REQUIRE((startedFuture.wait_for(std::chrono::seconds(5)) == std::future_status::ready));
// Let the statement enter sqlite3_step() so shutdown interrupts a running query.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
const auto shutdownStart = std::chrono::steady_clock::now();
sm->shutdown();
const auto shutdownElapsed = std::chrono::steady_clock::now() - shutdownStart;
REQUIRE((finishedFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready));
CHECK(finishedFuture.get());
CHECK((shutdownElapsed < shutdown_budget::kWorkCoordinatorJoinTimeout));
CHECK((sm->getServiceManagerFsmSnapshot().state == ServiceManagerState::Stopped));
const auto nextStartup = consumeDbCleanShutdownStamp(dbPath);
CHECK(nextStartup.trustedCleanShutdown);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager preserves metadata when integrity validation cannot finish",
"[daemon][service_manager][startup][integrity][cancellation]") {
const auto dbPath = metadataDbPath(config_);
seedMetadataDb(dbPath, "must-survive");
auto database = std::make_shared<metadata::Database>();
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
SECTION("shutdown cancels validation without recovery") {
REQUIRE(database->open(dbPath.string(), metadata::ConnectionMode::ReadWrite));
sm->shutdown();
}
SECTION("an unavailable connection is not evidence of corruption") {
// The on-disk database is valid; the supplied connection is closed.
}
CHECK_FALSE(sm->testingEnsureDatabaseIntegrityOrRecover(dbPath, database));
database->close();
CHECK_FALSE(findQuarantinedFile(config_.dataDir).has_value());
CHECK(state_.readiness.databaseRecoveredFrom.empty());
CHECK(startupProbeRowCount(dbPath) == 1);
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager records recovery provenance for corrupt metadata DB startup",
"[daemon][service_manager][startup][recovery]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
const auto dbPath = metadataDbPath(config_);
writeCorruptDb(dbPath);
seedDummyWalSidecar(dbPath, "corrupt-sidecar");
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
const auto smSnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((smSnap.state == ServiceManagerState::Ready));
requireReadyDatabaseState(state_);
std::optional<fs::path> quarantinedPath;
{
std::lock_guard<std::mutex> lk(state_.readiness.recoveryMutex);
REQUIRE_FALSE(state_.readiness.databaseRecoveredFrom.empty());
quarantinedPath = findQuarantinedFile(config_.dataDir);
REQUIRE(quarantinedPath.has_value());
CHECK((state_.readiness.databaseRecoveredFrom == quarantinedPath->string()));
}
CHECK(fs::exists(*quarantinedPath));
CHECK_FALSE(walSidecarStillMatchesPayload(dbPath, "corrupt-sidecar"));
sm->shutdown();
CHECK(walSidecarCleared(dbPath));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager startup salvage keeps the corrupt DB for retention",
"[daemon][service_manager][startup][recovery][salvage]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
// A readable earlier metadata DB with more documents than the (not yet created) live DB.
const auto corruptPath = config_.dataDir / "yams.db.corrupt-20200101T000000Z";
{
metadata::Database db;
REQUIRE(db.open(corruptPath.string(), metadata::ConnectionMode::Create));
REQUIRE(db.execute("CREATE TABLE documents (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"file_path TEXT NOT NULL, file_name TEXT NOT NULL, file_extension "
"TEXT, file_size INTEGER NOT NULL, sha256_hash TEXT UNIQUE NOT NULL, "
"mime_type TEXT, created_time INTEGER, modified_time INTEGER, "
"indexed_time INTEGER, content_extracted BOOLEAN DEFAULT 0, "
"extraction_status TEXT DEFAULT 'pending', extraction_error TEXT, "
"path_prefix TEXT, reverse_path TEXT, path_hash TEXT, parent_hash "
"TEXT, path_depth INTEGER DEFAULT 0, repair_status TEXT DEFAULT "
"'pending', repair_attempted_at INTEGER, repair_attempts INTEGER "
"DEFAULT 0)"));
for (int i = 0; i < 2; ++i) {
const auto n = std::to_string(i);
REQUIRE(db.execute("INSERT INTO documents (file_path, file_name, file_size, "
"sha256_hash) VALUES ('/salvage/" +
n + ".txt', '" + n + ".txt', 1, '" + std::string(63, 'a') + n +
"')"));
}
REQUIRE(db.execute("PRAGMA wal_checkpoint(TRUNCATE)"));
db.close();
}
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
REQUIRE(sm->initialize());
sm->startAsyncInit();
const auto smSnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((smSnap.state == ServiceManagerState::Ready));
requireReadyDatabaseState(state_);
CHECK(state_.readiness.databaseSalvaged.load(std::memory_order_acquire));
// Startup salvages but never deletes: removal is the repair service's job, after the
// retention window and only with evidence that every document reached the live DB.
CHECK(fs::exists(corruptPath));
sm->shutdown();
}
TEST_CASE_METHOD(ServiceManagerFixture, "ServiceManager clears stale metadata WAL during startup",
"[daemon][service_manager][wal][startup]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
const auto dbPath = metadataDbPath(config_);
seedMetadataDb(dbPath, "stale_wal_seed");
REQUIRE(publishDbCleanShutdownStamp(dbPath));
const std::string staleWalPayload = "stale-wal";
seedDummyWalSidecar(dbPath, staleWalPayload);
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
auto smSnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((smSnap.state == ServiceManagerState::Ready));
requireReadyDatabaseState(state_);
CHECK_FALSE(state_.readiness.databaseIntegrityFastPath.load(std::memory_order_acquire));
{
std::lock_guard<std::mutex> lk(state_.readiness.recoveryMutex);
CHECK(state_.readiness.databaseRecoveredFrom.empty());
}
CHECK_FALSE(walSidecarStillMatchesPayload(dbPath, staleWalPayload));
CHECK((startupProbeRowCount(dbPath) == 1U));
sm->shutdown();
CHECK(walSidecarCleared(dbPath));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager shutdown remains idempotent after async init reaches ready",
"[daemon][service_manager][shutdown][idempotent]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
const auto readySnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((readySnap.state == ServiceManagerState::Ready));
sm->shutdown();
const auto firstShutdown = sm->getServiceManagerFsmSnapshot();
CHECK((firstShutdown.state == ServiceManagerState::Stopped));
CHECK((sm->__test_getAbiHost() == nullptr));
REQUIRE_NOTHROW(sm->shutdown());
const auto secondShutdown = sm->getServiceManagerFsmSnapshot();
CHECK((secondShutdown.state == ServiceManagerState::Stopped));
CHECK((sm->getMetadataRepo() == nullptr));
CHECK((sm->getContentStore() == nullptr));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager shutdown waits for an in-flight worker task",
"[daemon][service_manager][shutdown][regression]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
config_.enableAutoRepair = false;
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
const auto readySnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((readySnap.state == ServiceManagerState::Ready));
struct SharedShutdownState {
std::mutex gateMutex;
std::condition_variable gateCv;
bool allowWorkerExit{false};
std::promise<void> workerEntered;
std::promise<void> workerFinished;
std::promise<void> shutdownFinished;
std::atomic<unsigned> completionSequence{0};
std::atomic<unsigned> workerCompletionOrder{0};
std::atomic<unsigned> shutdownCompletionOrder{0};
};
struct ShutdownCleanup {
explicit ShutdownCleanup(const std::shared_ptr<SharedShutdownState>& sharedState)
: state(sharedState) {}
void releaseWorker() noexcept {
{
std::lock_guard<std::mutex> lk(state->gateMutex);
state->allowWorkerExit = true;
}
state->gateCv.notify_all();
}
void join() noexcept {
if (shutdownThread.joinable()) {
shutdownThread.join();
}
}
~ShutdownCleanup() {
releaseWorker();
join();
}
std::shared_ptr<SharedShutdownState> state;
std::thread shutdownThread;
};
auto sharedState = std::make_shared<SharedShutdownState>();
auto workerEntered = sharedState->workerEntered.get_future();
auto workerFinished = sharedState->workerFinished.get_future();
auto shutdownFinished = sharedState->shutdownFinished.get_future();
ShutdownCleanup cleanup(sharedState);
boost::asio::post(sm->getWorkerExecutor(), [sharedState]() {
try {
sharedState->workerEntered.set_value();
std::unique_lock<std::mutex> lk(sharedState->gateMutex);
sharedState->gateCv.wait(lk, [sharedState] { return sharedState->allowWorkerExit; });
lk.unlock();
const auto order =
sharedState->completionSequence.fetch_add(1, std::memory_order_acq_rel) + 1;
sharedState->workerCompletionOrder.store(order, std::memory_order_release);
sharedState->workerFinished.set_value();
} catch (...) {
const auto error = std::current_exception();
try {
sharedState->workerEntered.set_exception(error);
} catch (...) {
}
try {
sharedState->workerFinished.set_exception(error);
} catch (...) {
}
}
});
REQUIRE((workerEntered.wait_for(std::chrono::seconds(5)) == std::future_status::ready));
REQUIRE_NOTHROW(workerEntered.get());
cleanup.shutdownThread = std::thread([sm, sharedState]() {
try {
sm->shutdown();
const auto order =
sharedState->completionSequence.fetch_add(1, std::memory_order_acq_rel) + 1;
sharedState->shutdownCompletionOrder.store(order, std::memory_order_release);
sharedState->shutdownFinished.set_value();
} catch (...) {
try {
sharedState->shutdownFinished.set_exception(std::current_exception());
} catch (...) {
}
}
});
// ServiceManagerFsm has no wait API for this non-terminal state. Poll the synchronized
// public snapshot with a deadline so the assertion observes shutdown entry, not elapsed time.
REQUIRE(yams::test::wait_for_condition(
std::chrono::seconds(5), std::chrono::milliseconds(5), [sm]() {
return sm->getServiceManagerFsmSnapshot().state == ServiceManagerState::ShuttingDown;
}));
CHECK((workerFinished.wait_for(std::chrono::seconds(0)) == std::future_status::timeout));
CHECK((shutdownFinished.wait_for(std::chrono::seconds(0)) == std::future_status::timeout));
cleanup.releaseWorker();
REQUIRE((workerFinished.wait_for(std::chrono::seconds(5)) == std::future_status::ready));
REQUIRE_NOTHROW(workerFinished.get());
REQUIRE((shutdownFinished.wait_for(std::chrono::seconds(30)) == std::future_status::ready));
REQUIRE_NOTHROW(shutdownFinished.get());
cleanup.join();
CHECK((sharedState->workerCompletionOrder.load(std::memory_order_acquire) <
sharedState->shutdownCompletionOrder.load(std::memory_order_acquire)));
CHECK((sm->getServiceManagerFsmSnapshot().state == ServiceManagerState::Stopped));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager shutdown remains bounded during auto-repair graph health probe",
"[daemon][service_manager][shutdown][auto_repair][regression]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
config_.enableAutoRepair = false;
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
const auto readySnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((readySnap.state == ServiceManagerState::Ready));
auto writePool = sm->getWriteConnectionPool();
REQUIRE((writePool != nullptr));
std::vector<std::unique_ptr<metadata::PooledConnection>> heldConnections;
heldConnections.reserve(32);
for (std::size_t i = 0; i < 128; ++i) {
auto conn = writePool->acquire(std::chrono::milliseconds(0));
if (!conn) {
break;
}
heldConnections.emplace_back();
std::swap(heldConnections.back(), conn.value());
}
REQUIRE_FALSE(heldConnections.empty());
REQUIRE_FALSE(writePool->acquire(std::chrono::milliseconds(0)).has_value());
std::atomic<bool> probeStarted{false};
std::atomic<bool> probeFinished{false};
std::atomic<bool> shutdownReturned{false};
boost::asio::post(sm->getWorkerExecutor(), [sm, &probeStarted, &probeFinished]() {
probeStarted.store(true, std::memory_order_release);
repair::RepairHealthProbe probe(sm->getMetadataRepo(), sm->getVectorDatabase(),
sm->getKgStore());
repair::RepairHealthOptions opts;
opts.checkFts5 = false;
opts.checkEmbeddings = false;
opts.checkGraph = true;
opts.scanDocuments = false;
(void)probe.probe(opts);
probeFinished.store(true, std::memory_order_release);
});
REQUIRE(yams::test::wait_for_condition(
std::chrono::seconds(5), std::chrono::milliseconds(5),
[&]() { return probeStarted.load(std::memory_order_acquire); }));
std::thread shutdownThread([&]() {
sm->shutdown();
shutdownReturned.store(true, std::memory_order_release);
});
REQUIRE(yams::test::wait_for_condition(
std::chrono::seconds(5), std::chrono::milliseconds(5), [&]() {
return shutdownReturned.load(std::memory_order_acquire) &&
probeFinished.load(std::memory_order_acquire);
}));
shutdownThread.join();
CHECK(shutdownReturned.load(std::memory_order_acquire));
CHECK(probeFinished.load(std::memory_order_acquire));
CHECK((sm->getServiceManagerFsmSnapshot().state == ServiceManagerState::Stopped));
heldConnections.clear();
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager shutdown cancels salvage auto-repair with full embed queue",
"[daemon][service_manager][shutdown][auto_repair][cancellation][regression]") {
yams::test::ScopedEnvVar disableVectors("YAMS_DISABLE_VECTORS",
std::optional<std::string>{"1"});
yams::test::ScopedEnvVar disableVectorDb("YAMS_DISABLE_VECTOR_DB",
std::optional<std::string>{"1"});
yams::test::ScopedEnvVar autoInitialDelay("YAMS_REPAIR_AUTO_INITIAL_DELAY_MIN",
std::optional<std::string>{"0"});
yams::test::ScopedEnvVar autoFastInterval("YAMS_REPAIR_AUTO_FAST_MIN",
std::optional<std::string>{"1"});
yams::test::ScopedEnvVar autoWarmInterval("YAMS_REPAIR_AUTO_WARM_HOURS",
std::optional<std::string>{"0"});
yams::test::ScopedEnvVar autoColdInterval("YAMS_REPAIR_AUTO_COLD_HOURS",
std::optional<std::string>{"0"});
yams::test::ScopedEnvVar embedChannelCapacity("YAMS_EMBED_CHANNEL_CAPACITY",
std::optional<std::string>{"256"});
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
config_.enableAutoRepair = true;
config_.autoRepairBatchSize = 1;
auto sm = std::make_shared<ServiceManager>(config_, state_, lifecycleFsm_);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
const auto readySnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((readySnap.state == ServiceManagerState::Ready));
auto meta = sm->getMetadataRepo();
REQUIRE((meta != nullptr));
const auto now =
std::chrono::time_point_cast<std::chrono::seconds>(std::chrono::system_clock::now());
metadata::DocumentInfo doc{};
doc.fileName = "salvaged-auto-repair.txt";
doc.filePath = (config_.dataDir / doc.fileName).string();
doc.fileExtension = "txt";
doc.fileSize = 26;
doc.sha256Hash = std::string(64, 'a');
doc.mimeType = "text/plain";
doc.modifiedTime = now;
doc.indexedTime = now;
auto docId = meta->insertDocument(doc);
REQUIRE(docId.has_value());
metadata::DocumentContent content{};
content.documentId = docId.value();
content.contentText = "salvage auto repair content";
content.contentLength = static_cast<int64_t>(content.contentText.size());
content.extractionMethod = "test";
content.language = "en";
REQUIRE(meta->insertContent(content).has_value());
REQUIRE(meta->updateDocumentExtractionStatus(docId.value(), true,
metadata::ExtractionStatus::Success)
.has_value());
{
std::lock_guard<std::mutex> lk(state_.readiness.recoveryMutex);
state_.readiness.databaseRecoveredFrom = "salvaged-auto-repair";
}
auto embedChannel =
InternalEventBus::instance().get_or_create_channel<InternalEventBus::EmbedJob>("embed_jobs",
256);
REQUIRE((embedChannel != nullptr));
InternalEventBus::EmbedJob drained;
while (embedChannel->try_pop(drained)) {
}
InternalEventBus::EmbedJob filler;
filler.hashes = {std::string(64, 'f')};
filler.batchSize = 1;
filler.skipExisting = true;
std::size_t filled = 0;
while (embedChannel->try_push(filler)) {
++filled;
}
REQUIRE((filled > 0));
sm->startRepairService([]() -> size_t { return 0; });
auto repairService = sm->getRepairServiceShared();
REQUIRE((repairService != nullptr));
sm->startBackgroundTasks();
REQUIRE(yams::test::wait_for_condition(std::chrono::seconds(5), std::chrono::milliseconds(10),
[&]() { return repairService->isRepairInProgress(); }));
std::atomic<bool> shutdownReturned{false};
const auto shutdownStart = std::chrono::steady_clock::now();
std::thread shutdownThread([&]() {
sm->shutdown();
shutdownReturned.store(true, std::memory_order_release);
});
REQUIRE(yams::test::wait_for_condition(
std::chrono::seconds(3), std::chrono::milliseconds(10),
[&]() { return shutdownReturned.load(std::memory_order_acquire); }));
const auto shutdownElapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - shutdownStart);
shutdownThread.join();
CHECK((shutdownElapsed < std::chrono::seconds(3)));
CHECK((sm->getServiceManagerFsmSnapshot().state == ServiceManagerState::Stopped));
CHECK_FALSE(state_.stats.repairInProgress.load(std::memory_order_acquire));
}
TEST_CASE_METHOD(ServiceManagerFixture,
"ServiceManager restart cycles recover freshly seeded stale WAL",
"[daemon][service_manager][wal][restart]") {
config_.enableModelProvider = false;
config_.useMockModelProvider = false;
config_.autoLoadPlugins = false;
const auto dbPath = metadataDbPath(config_);
seedMetadataDb(dbPath, "restart_cycle_seed");
for (int cycle = 0; cycle < 3; ++cycle) {
StateComponent cycleState;
DaemonLifecycleFsm cycleLifecycle;
const std::string staleWalPayload = "wal-cycle-" + std::to_string(cycle);
seedDummyWalSidecar(dbPath, staleWalPayload);
auto sm = std::make_shared<ServiceManager>(config_, cycleState, cycleLifecycle);
auto init = sm->initialize();
REQUIRE(init);
sm->startAsyncInit();
auto smSnap = sm->waitForServiceManagerTerminalState(30);
REQUIRE((smSnap.state == ServiceManagerState::Ready));
INFO("cycle=" << cycle);
requireReadyDatabaseState(cycleState);
CHECK_FALSE(walSidecarStillMatchesPayload(dbPath, staleWalPayload));
CHECK((startupProbeRowCount(dbPath) == 1U));
sm->shutdown();
CHECK(walSidecarCleared(dbPath));
}
}
TEST_CASE("ServiceManager auto-vacuum requires material reclaimable pages or file tail",
"[daemon][service_manager][vacuum]") {
constexpr std::uint64_t kPageSize = 4096;
constexpr std::uint64_t kLargeDatabaseBytes = 13ULL * 1024 * 1024 * 1024;
CHECK_FALSE(
ServiceManager::__test_shouldAutoVacuum(kLargeDatabaseBytes, 3'405'384, 182, kPageSize));
CHECK_FALSE(
ServiceManager::__test_shouldAutoVacuum(256ULL * 1024 * 1024, 65'536, 20'000, kPageSize));
CHECK(ServiceManager::__test_shouldAutoVacuum(2ULL * 1024 * 1024 * 1024, 524'288, 131'072,
kPageSize));
CHECK(ServiceManager::__test_shouldAutoVacuum(600ULL * 1024 * 1024, 8, 0, kPageSize));
}
} // namespace yams::daemon::test