The test drives backfill by hand and expects one published document per cycle. A background apply cycle runs its own backfill the first time it fires; on a slow runner that landed between the test's cycles and published an extra document (macOS CI: 3 == 2 at cycle 2). Defer the background backfill schedule for this test. Signed-off-by: trvon <me@trevon.dev>
2019 lines
95 KiB
C++
2019 lines
95 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright 2026 YAMS Contributors
|
|
|
|
// pi-lens-ignore: fatal error
|
|
#include <nlohmann/json.hpp>
|
|
#include <spdlog/spdlog.h>
|
|
#include <catch2/catch_message.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/ip/tcp.hpp>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <yams/api/content_store.h>
|
|
#include <yams/crypto/hasher.h>
|
|
#include <yams/daemon/client/daemon_client.h>
|
|
#include <yams/daemon/components/HostPressure.h>
|
|
#include <yams/daemon/components/ResourceGovernor.h>
|
|
#include <yams/daemon/components/ServiceManager.h>
|
|
#include <yams/daemon/ipc/ipc_protocol.h>
|
|
#include <yams/mcp/mcp_server.h>
|
|
#include <yams/memory_sync/memory_sync_service.h>
|
|
#include <yams/memory_sync/records.h>
|
|
#include <yams/memory_sync/writer_auth.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/storage/storage_backend.h>
|
|
|
|
#include "test_async_helpers.h"
|
|
#include "test_daemon_harness.h"
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
namespace {
|
|
|
|
#ifndef YAMS_TEST_TIMEOUT_SCALE
|
|
#define YAMS_TEST_TIMEOUT_SCALE 1
|
|
#endif
|
|
constexpr int kTestTimeoutScale = YAMS_TEST_TIMEOUT_SCALE;
|
|
|
|
template <typename Rep, typename Period>
|
|
constexpr auto scaledTimeout(std::chrono::duration<Rep, Period> timeout) {
|
|
return timeout * kTestTimeoutScale;
|
|
}
|
|
|
|
std::vector<std::byte> bytes(std::string_view value) {
|
|
std::vector<std::byte> result(value.size());
|
|
std::memcpy(result.data(), value.data(), value.size());
|
|
return result;
|
|
}
|
|
|
|
std::string digest(std::span<const std::byte> data) {
|
|
yams::crypto::SHA256Hasher hasher;
|
|
hasher.init();
|
|
hasher.update(data);
|
|
return hasher.finalize();
|
|
}
|
|
|
|
/// Delegates every operation to the real daemon content store except
|
|
/// `retrieveBytes` for one poisoned hash, which fails with the same
|
|
/// manifest-corruption error the live backfill wedge produced. Used to prove
|
|
/// the backfill skips an unrecoverable blob instead of wedging the document
|
|
/// domain.
|
|
class ManifestPoisonedContentStore final : public yams::api::IContentStore {
|
|
public:
|
|
ManifestPoisonedContentStore(std::shared_ptr<yams::api::IContentStore> inner,
|
|
std::string poisonedHash)
|
|
: inner_(std::move(inner)), poisonedHash_(std::move(poisonedHash)) {}
|
|
|
|
yams::Result<yams::api::StoreResult> store(const std::filesystem::path& path,
|
|
const yams::api::ContentMetadata& metadata,
|
|
yams::api::ProgressCallback progress) override {
|
|
return inner_->store(path, metadata, std::move(progress));
|
|
}
|
|
yams::Result<yams::api::RetrieveResult>
|
|
retrieve(const std::string& hash, const std::filesystem::path& outputPath,
|
|
yams::api::ProgressCallback progress) override {
|
|
return inner_->retrieve(hash, outputPath, std::move(progress));
|
|
}
|
|
yams::Result<yams::api::StoreResult>
|
|
storeStream(std::istream& stream, const yams::api::ContentMetadata& metadata,
|
|
yams::api::ProgressCallback progress) override {
|
|
return inner_->storeStream(stream, metadata, std::move(progress));
|
|
}
|
|
yams::Result<yams::api::RetrieveResult>
|
|
retrieveStream(const std::string& hash, std::ostream& output,
|
|
yams::api::ProgressCallback progress) override {
|
|
return inner_->retrieveStream(hash, output, std::move(progress));
|
|
}
|
|
yams::Result<yams::api::StoreResult>
|
|
storeBytes(std::span<const std::byte> data,
|
|
const yams::api::ContentMetadata& metadata) override {
|
|
return inner_->storeBytes(data, metadata);
|
|
}
|
|
yams::Result<std::vector<std::byte>> retrieveBytes(const std::string& hash) override {
|
|
if (hash == poisonedHash_) {
|
|
return yams::Error{yams::ErrorCode::ManifestInvalid, "test: poisoned content manifest"};
|
|
}
|
|
return inner_->retrieveBytes(hash);
|
|
}
|
|
yams::Result<std::vector<std::byte>> retrieveBytesPrefix(const std::string& hash,
|
|
std::size_t maxBytes) override {
|
|
if (hash == poisonedHash_) {
|
|
return yams::Error{yams::ErrorCode::ManifestInvalid, "test: poisoned content manifest"};
|
|
}
|
|
return inner_->retrieveBytesPrefix(hash, maxBytes);
|
|
}
|
|
yams::Result<yams::api::IContentStore::RawContent>
|
|
retrieveRaw(const std::string& hash) override {
|
|
return inner_->retrieveRaw(hash);
|
|
}
|
|
std::future<yams::Result<yams::api::IContentStore::RawContent>>
|
|
retrieveRawAsync(const std::string& hash) override {
|
|
return inner_->retrieveRawAsync(hash);
|
|
}
|
|
yams::Result<bool> exists(const std::string& hash) const override {
|
|
return inner_->exists(hash);
|
|
}
|
|
yams::Result<bool> remove(const std::string& hash) override { return inner_->remove(hash); }
|
|
yams::Result<yams::api::ContentMetadata> getMetadata(const std::string& hash) const override {
|
|
return inner_->getMetadata(hash);
|
|
}
|
|
yams::Result<void> updateMetadata(const std::string& hash,
|
|
const yams::api::ContentMetadata& metadata) override {
|
|
return inner_->updateMetadata(hash, metadata);
|
|
}
|
|
std::vector<yams::Result<yams::api::StoreResult>>
|
|
storeBatch(const std::vector<std::filesystem::path>& paths,
|
|
const std::vector<yams::api::ContentMetadata>& metadata) override {
|
|
return inner_->storeBatch(paths, metadata);
|
|
}
|
|
std::vector<yams::Result<bool>> removeBatch(const std::vector<std::string>& hashes) override {
|
|
return inner_->removeBatch(hashes);
|
|
}
|
|
yams::api::ContentStoreStats getStats() const override { return inner_->getStats(); }
|
|
yams::api::HealthStatus checkHealth() const override { return inner_->checkHealth(); }
|
|
yams::Result<void> verify(yams::api::ProgressCallback progress) override {
|
|
return inner_->verify(std::move(progress));
|
|
}
|
|
yams::Result<void> compact(yams::api::ProgressCallback progress) override {
|
|
return inner_->compact(std::move(progress));
|
|
}
|
|
yams::Result<void> garbageCollect(yams::api::ProgressCallback progress) override {
|
|
return inner_->garbageCollect(std::move(progress));
|
|
}
|
|
|
|
private:
|
|
std::shared_ptr<yams::api::IContentStore> inner_;
|
|
std::string poisonedHash_;
|
|
};
|
|
|
|
// Functional tests control the sampled host signal, not governor thresholds or admission.
|
|
// The platform sampler is restored after the harness and all of its workers are destroyed.
|
|
class ScopedHostPressureSample {
|
|
public:
|
|
ScopedHostPressureSample() { setCalm(); }
|
|
~ScopedHostPressureSample() {
|
|
auto& governor = yams::daemon::ResourceGovernor::instance();
|
|
governor.testing_setHostPressureSampler(nullptr);
|
|
governor.tick(nullptr);
|
|
}
|
|
ScopedHostPressureSample(const ScopedHostPressureSample&) = delete;
|
|
ScopedHostPressureSample& operator=(const ScopedHostPressureSample&) = delete;
|
|
|
|
void setCalm() { setIoPressure(0.0); }
|
|
void setBusy() { setIoPressure(100.0); }
|
|
|
|
private:
|
|
static void setIoPressure(double ioPressure) {
|
|
yams::daemon::HostPressureSample sample;
|
|
sample.source = yams::daemon::HostPressureSource::Psi;
|
|
sample.cpuSomeAvg10 = 0.0;
|
|
sample.ioSomeAvg10 = ioPressure;
|
|
sample.memorySomeAvg10 = 0.0;
|
|
sample.loadPerCpu = 0.0;
|
|
auto& governor = yams::daemon::ResourceGovernor::instance();
|
|
governor.testing_setHostPressureSampler([sample] { return sample; });
|
|
governor.tick(nullptr);
|
|
}
|
|
};
|
|
|
|
yams::test::DaemonHarness::Options makeMemorySyncHarnessOptions(std::string nodeId,
|
|
std::string corpusId) {
|
|
yams::test::DaemonHarness::Options options;
|
|
options.enableModelProvider = false;
|
|
options.useMockModelProvider = false;
|
|
options.autoLoadPlugins = false;
|
|
options.enableAutoRepair = false;
|
|
options.isolateConfig = true;
|
|
options.isolateState = true;
|
|
options.configureDaemon = [nodeId, corpusId](yams::daemon::DaemonConfig& config) {
|
|
config.memorySync.enabled = true;
|
|
config.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
|
|
config.memorySync.nodeId = nodeId;
|
|
config.memorySync.corpusId = corpusId;
|
|
config.memorySync.corpusEpoch = 1;
|
|
config.memorySync.transport = "shared-store";
|
|
config.memorySync.backend = "filesystem";
|
|
config.memorySync.path = "shared-memory";
|
|
config.memorySync.syncIntervalMs = 60'000;
|
|
};
|
|
std::ostringstream toml;
|
|
toml << "[daemon]\n"
|
|
<< "auto_load_plugins = false\n"
|
|
<< "auto_repair = false\n\n"
|
|
<< "[memory_sync]\n"
|
|
<< "enabled = true\n"
|
|
<< "corpus_scope = \"shared\"\n"
|
|
<< "node_id = \"" << nodeId << "\"\n"
|
|
<< "corpus_id = \"" << corpusId << "\"\n"
|
|
<< "corpus_epoch = 1\n"
|
|
<< "backend = \"filesystem\"\n"
|
|
<< "path = \"shared-memory\"\n"
|
|
<< "sync_interval_ms = 60000\n";
|
|
options.isolatedConfigContents = toml.str();
|
|
return options;
|
|
}
|
|
|
|
// The daemon's own memory_sync worker reconciles once as soon as it starts and then runs the
|
|
// apply/backfill callbacks on its thread. On a slow host that first cycle can land after the
|
|
// harness reports ready, inside a test's observer window. Stage observers that count calls made
|
|
// by the test tag the test's caller threads and ignore stages reported from any other thread.
|
|
thread_local bool tlsTestStageCaller = false;
|
|
|
|
template <typename Fn> void asTestStageCaller(Fn&& fn) {
|
|
tlsTestStageCaller = true;
|
|
struct Reset {
|
|
~Reset() { tlsTestStageCaller = false; }
|
|
} reset;
|
|
std::forward<Fn>(fn)();
|
|
}
|
|
|
|
// Stops a sync service from inside an apply or backfill stage observer. The observer runs
|
|
// under ServiceManager's apply lock, and a blocking stop() there takes the service's callback
|
|
// lock in the opposite order from the worker (TSan lock-order-inversion; the worker can also be
|
|
// waiting on the apply lock while stop() joins it). stop() runs on a helper thread instead;
|
|
// request() returns once the stop flag is visible, and join() runs after the stage call returns.
|
|
class StopFromStage {
|
|
public:
|
|
void request(yams::memory_sync::MemorySyncService& sync) {
|
|
if (stopper_.joinable()) {
|
|
return;
|
|
}
|
|
stopper_ = std::thread([&sync] { sync.stop(); });
|
|
while (!sync.stopRequested()) {
|
|
std::this_thread::yield();
|
|
}
|
|
}
|
|
void join() {
|
|
if (stopper_.joinable()) {
|
|
stopper_.join();
|
|
}
|
|
}
|
|
~StopFromStage() { join(); }
|
|
|
|
private:
|
|
std::thread stopper_;
|
|
};
|
|
|
|
std::unique_ptr<yams::storage::FilesystemBackend>
|
|
makeFilesystemBackend(const std::filesystem::path& path) {
|
|
yams::storage::BackendConfig config;
|
|
config.type = "filesystem";
|
|
config.localPath = path;
|
|
|
|
auto backend = std::make_unique<yams::storage::FilesystemBackend>();
|
|
REQUIRE(backend->initialize(config).has_value());
|
|
return backend;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Daemon refuses enabled memory sync when writer authentication cannot initialize",
|
|
"[integration][daemon][memory-sync][auth][startup]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174099",
|
|
"startup-failure-corpus");
|
|
options.configureDaemon = [](yams::daemon::DaemonConfig& config) {
|
|
config.memorySync.enabled = true;
|
|
config.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
|
|
config.memorySync.nodeId = "123e4567-e89b-42d3-a456-426614174099";
|
|
config.memorySync.corpusId = "startup-failure-corpus";
|
|
config.memorySync.corpusEpoch = 1;
|
|
config.memorySync.transport = "shared-store";
|
|
config.memorySync.backend = "filesystem";
|
|
config.memorySync.path = "shared-memory";
|
|
config.memorySync.writerAuthRequired = true;
|
|
config.memorySync.writerAuthManifestPath =
|
|
"/definitely-missing-yams-writer-auth-manifest.json";
|
|
};
|
|
|
|
yams::test::DaemonHarness harness(std::move(options));
|
|
CHECK_FALSE(harness.start(2s));
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync periodically converges and stops cleanly",
|
|
"[integration][daemon][memory-sync]") {
|
|
yams::test::DaemonHarness::Options options;
|
|
options.enableModelProvider = false;
|
|
options.useMockModelProvider = false;
|
|
options.autoLoadPlugins = false;
|
|
options.enableAutoRepair = false;
|
|
options.isolateConfig = true;
|
|
options.isolateState = true;
|
|
options.configureDaemon = [](yams::daemon::DaemonConfig& config) {
|
|
config.memorySync.enabled = true;
|
|
config.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
|
|
config.memorySync.nodeId = "123e4567-e89b-42d3-a456-426614174000";
|
|
config.memorySync.corpusId = "integration-corpus";
|
|
config.memorySync.corpusEpoch = 1;
|
|
config.memorySync.transport = "shared-store";
|
|
config.memorySync.backend = "filesystem";
|
|
config.memorySync.path = "shared-memory";
|
|
config.memorySync.syncIntervalMs = 25;
|
|
};
|
|
options.isolatedConfigContents = R"(
|
|
[daemon]
|
|
auto_load_plugins = false
|
|
auto_repair = false
|
|
|
|
[memory_sync]
|
|
enabled = true
|
|
corpus_scope = "shared"
|
|
node_id = "123e4567-e89b-42d3-a456-426614174000"
|
|
corpus_id = "integration-corpus"
|
|
corpus_epoch = 1
|
|
backend = "filesystem"
|
|
path = "shared-memory"
|
|
sync_interval_ms = 25
|
|
)";
|
|
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
REQUIRE(harness.daemon() != nullptr);
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
REQUIRE(daemonSync->started());
|
|
|
|
const auto sharedPath = harness.dataDir() / "shared-memory";
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(sharedPath),
|
|
yams::memory_sync::MemorySyncConfig{"peer-node", 25, "integration-corpus", 1}};
|
|
REQUIRE(peer.publish("convergence-key", bytes("from-peer")).has_value());
|
|
|
|
const auto deadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while (!daemonSync->testingHasMergedRecord("convergence-key") &&
|
|
std::chrono::steady_clock::now() < deadline) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
CHECK(daemonSync->testingHasMergedRecord("convergence-key"));
|
|
|
|
// Production daemon application uses ContentBlobSyncAdapter, including its
|
|
// digest check, rather than a parallel unchecked content-store path.
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(contentStore != nullptr);
|
|
const auto peerBlob = bytes("peer-content-blob");
|
|
const auto peerBlobHash = digest(peerBlob);
|
|
REQUIRE(peer.publish("content-blob/" + peerBlobHash, peerBlob).has_value());
|
|
bool peerBlobApplied = false;
|
|
const auto blobDeadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while (!peerBlobApplied && std::chrono::steady_clock::now() < blobDeadline) {
|
|
const auto exists = contentStore->exists(peerBlobHash);
|
|
REQUIRE(exists.has_value());
|
|
peerBlobApplied = exists.value();
|
|
if (!peerBlobApplied) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
}
|
|
REQUIRE(peerBlobApplied);
|
|
const auto hydratedBlob = contentStore->retrieveBytes(peerBlobHash);
|
|
REQUIRE(hydratedBlob.has_value());
|
|
CHECK(hydratedBlob.value() == peerBlob);
|
|
|
|
const std::string& kDocumentHash = peerBlobHash;
|
|
yams::memory_sync::MetadataDocumentRecord record;
|
|
record.documentId = kDocumentHash;
|
|
record.contentHash = kDocumentHash;
|
|
record.filePath = "/peer/corpus/daemon-applied.md";
|
|
record.fileName = "daemon-applied.md";
|
|
record.fileExtension = ".md";
|
|
record.fileSize = 42;
|
|
record.mimeType = "text/markdown";
|
|
record.createdTime = 1000;
|
|
record.modifiedTime = 1001;
|
|
record.indexedTime = 1002;
|
|
record.contentExtracted = true;
|
|
record.metadata["source"] = {.value = "peer", .type = 0};
|
|
const auto serialized = nlohmann::json(record).dump();
|
|
REQUIRE(peer.publish("document/" + std::string(kDocumentHash), bytes(serialized)).has_value());
|
|
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
std::optional<yams::metadata::DocumentInfo> replicated;
|
|
const auto applyDeadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while (!replicated && std::chrono::steady_clock::now() < applyDeadline) {
|
|
auto found = repository->getDocumentByHash(std::string(kDocumentHash));
|
|
REQUIRE(found.has_value());
|
|
replicated = found.value();
|
|
if (!replicated) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
}
|
|
REQUIRE(replicated.has_value());
|
|
CHECK(replicated->filePath == record.filePath);
|
|
|
|
const auto source = repository->getMetadata(replicated->id, "source");
|
|
REQUIRE(source.has_value());
|
|
REQUIRE(source.value().has_value());
|
|
CHECK(source.value()->value == "peer");
|
|
|
|
// A document committed after daemon startup is discovered by bounded periodic backfill.
|
|
const auto backfillAttemptsBefore = serviceManager->testingMemorySyncBackfillAttempts();
|
|
const auto localPayload = bytes("periodic-backfill-search-needle");
|
|
auto stored = contentStore->storeBytes(localPayload);
|
|
REQUIRE(stored.has_value());
|
|
|
|
yams::metadata::BatchDocumentInsert localInsert;
|
|
localInsert.info.filePath = "/local/corpus/backfill.md";
|
|
localInsert.info.fileName = "backfill.md";
|
|
localInsert.info.fileExtension = ".md";
|
|
localInsert.info.fileSize = static_cast<std::int64_t>(localPayload.size());
|
|
localInsert.info.sha256Hash = stored.value().contentHash;
|
|
localInsert.info.mimeType = "text/markdown";
|
|
localInsert.tags.emplace_back("origin", yams::metadata::MetadataValue{"daemon"});
|
|
std::vector<yams::metadata::BatchDocumentInsert> localInserts;
|
|
localInserts.push_back(std::move(localInsert));
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(localInserts).has_value());
|
|
|
|
auto kgStore = serviceManager->getKgStore();
|
|
REQUIRE(kgStore != nullptr);
|
|
yams::metadata::KGNode fileNode;
|
|
fileNode.nodeKey = "file:/local/corpus/backfill.md";
|
|
fileNode.type = "file";
|
|
fileNode.label = "backfill.md";
|
|
auto fileNodeId = kgStore->upsertNode(fileNode);
|
|
REQUIRE(fileNodeId.has_value());
|
|
yams::metadata::KGNode symbolNode;
|
|
symbolNode.nodeKey = "fn:backfill";
|
|
symbolNode.type = "function";
|
|
symbolNode.label = "backfill";
|
|
auto symbolNodeId = kgStore->upsertNode(symbolNode);
|
|
REQUIRE(symbolNodeId.has_value());
|
|
yams::metadata::KGEdge edge;
|
|
edge.srcNodeId = fileNodeId.value();
|
|
edge.dstNodeId = symbolNodeId.value();
|
|
edge.relation = "defines";
|
|
REQUIRE(kgStore->addEdge(edge).has_value());
|
|
|
|
const std::string documentKey = "document/" + stored.value().contentHash;
|
|
const std::string blobKey = "content-blob/" + stored.value().contentHash;
|
|
const std::string topologyNodeKey = "topology-node/file%3A%2Flocal%2Fcorpus%2Fbackfill.md";
|
|
const std::string topologyEdgeKey =
|
|
"topology-edge/file%3A%2Flocal%2Fcorpus%2Fbackfill.md/defines/fn%3Abackfill";
|
|
bool documentPublished = false;
|
|
bool blobPublished = false;
|
|
bool topologyNodePublished = false;
|
|
bool topologyEdgePublished = false;
|
|
const auto backfillDeadline = std::chrono::steady_clock::now() + scaledTimeout(8s);
|
|
while ((!documentPublished || !blobPublished || !topologyNodePublished ||
|
|
!topologyEdgePublished ||
|
|
serviceManager->testingMemorySyncBackfillAttempts() <= backfillAttemptsBefore) &&
|
|
std::chrono::steady_clock::now() < backfillDeadline) {
|
|
documentPublished = peer.read(documentKey).has_value();
|
|
auto replicatedBlob = peer.read(blobKey);
|
|
blobPublished = replicatedBlob.has_value() && replicatedBlob.value() == localPayload;
|
|
topologyNodePublished = peer.read(topologyNodeKey).has_value();
|
|
topologyEdgePublished = peer.read(topologyEdgeKey).has_value();
|
|
if (!documentPublished || !blobPublished || !topologyNodePublished ||
|
|
!topologyEdgePublished ||
|
|
serviceManager->testingMemorySyncBackfillAttempts() <= backfillAttemptsBefore) {
|
|
// Backfill runs at most one 100 ms pass per 5 s. Let every periodic sync cycle run a
|
|
// pass, so a slow runner is bounded by publish cost rather than by that cadence.
|
|
serviceManager->testingExpireMemorySyncBackfillSchedule();
|
|
std::this_thread::sleep_for(25ms);
|
|
}
|
|
}
|
|
const auto backfillAttemptsAfter = serviceManager->testingMemorySyncBackfillAttempts();
|
|
INFO("Periodic backfill attempts before=" << backfillAttemptsBefore
|
|
<< " after=" << backfillAttemptsAfter);
|
|
CHECK(backfillAttemptsAfter > backfillAttemptsBefore);
|
|
CHECK(documentPublished);
|
|
CHECK(blobPublished);
|
|
CHECK(topologyNodePublished);
|
|
CHECK(topologyEdgePublished);
|
|
|
|
// Persistent-mode tombstones converge through the daemon's production adapters.
|
|
REQUIRE(peer.erase("document/" + std::string(kDocumentHash), std::string(kDocumentHash))
|
|
.has_value());
|
|
REQUIRE(peer.erase("content-blob/" + peerBlobHash, peerBlobHash).has_value());
|
|
bool documentDeleted = false;
|
|
bool blobDeleted = false;
|
|
const auto deleteDeadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while ((!documentDeleted || !blobDeleted) &&
|
|
std::chrono::steady_clock::now() < deleteDeadline) {
|
|
auto found = repository->getDocumentByHash(std::string(kDocumentHash));
|
|
REQUIRE(found.has_value());
|
|
documentDeleted = !found.value().has_value();
|
|
const auto exists = contentStore->exists(peerBlobHash);
|
|
REQUIRE(exists.has_value());
|
|
blobDeleted = !exists.value();
|
|
if (!documentDeleted || !blobDeleted) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
}
|
|
CHECK(documentDeleted);
|
|
CHECK(blobDeleted);
|
|
|
|
REQUIRE(serviceManager->publishMemorySync("user-delete", "value").has_value());
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
REQUIRE(peer.readCached("user/user-delete").has_value());
|
|
REQUIRE(serviceManager->deleteMemorySync("user-delete").has_value());
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached("user/user-delete").has_value());
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Production document deletion also replicates content-byte deletion",
|
|
"[integration][daemon][memory-sync][deletion]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174020",
|
|
"delete-integrity-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(contentStore != nullptr);
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
|
|
const auto payload = bytes("production-delete-content");
|
|
auto stored = contentStore->storeBytes(payload);
|
|
REQUIRE(stored.has_value());
|
|
const auto& hash = stored.value().contentHash;
|
|
|
|
yams::metadata::BatchDocumentInsert insert;
|
|
insert.info.filePath = "/local/corpus/delete-me.md";
|
|
insert.info.fileName = "delete-me.md";
|
|
insert.info.fileExtension = ".md";
|
|
insert.info.fileSize = static_cast<std::int64_t>(payload.size());
|
|
insert.info.sha256Hash = hash;
|
|
insert.info.mimeType = "text/markdown";
|
|
std::vector<yams::metadata::BatchDocumentInsert> inserts;
|
|
inserts.push_back(std::move(insert));
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(inserts).has_value());
|
|
|
|
yams::memory_sync::MetadataDocumentRecord record;
|
|
record.documentId = hash;
|
|
record.contentHash = hash;
|
|
record.filePath = "/local/corpus/delete-me.md";
|
|
record.fileName = "delete-me.md";
|
|
record.fileExtension = ".md";
|
|
record.fileSize = static_cast<std::int64_t>(payload.size());
|
|
record.mimeType = "text/markdown";
|
|
const std::string documentKey = "document/" + hash;
|
|
const std::string blobKey = "content-blob/" + hash;
|
|
const auto serialized = nlohmann::json(record).dump();
|
|
REQUIRE(daemonSync->publish(documentKey, bytes(serialized)).has_value());
|
|
REQUIRE(daemonSync->publish(blobKey, payload).has_value());
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"delete-peer", 60'000, "delete-integrity-corpus", 1}};
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
REQUIRE(peer.readCached(documentKey).has_value());
|
|
REQUIRE(peer.readCached(blobKey).has_value());
|
|
|
|
// Exercise the real IPC dispatcher, including its pre-delete staging and post-delete
|
|
// publication hooks.
|
|
yams::daemon::ClientConfig clientConfig;
|
|
clientConfig.socketPath = harness.socketPath();
|
|
clientConfig.connectTimeout = 5s;
|
|
clientConfig.requestTimeout = 10s;
|
|
clientConfig.autoStart = false;
|
|
yams::daemon::DaemonClient client{clientConfig};
|
|
REQUIRE(yams::cli::run_sync(client.connect(), 5s).has_value());
|
|
yams::daemon::DeleteRequest deleteRequest;
|
|
deleteRequest.hash = hash;
|
|
const auto writerCounterBeforeDelete =
|
|
peer.currentVersion().get("123e4567-e89b-42d3-a456-426614174020");
|
|
std::promise<void> publisherLockedPromise;
|
|
auto publisherLocked = publisherLockedPromise.get_future();
|
|
std::promise<void> drainerWaitingPromise;
|
|
auto drainerWaiting = drainerWaitingPromise.get_future();
|
|
std::promise<void> releasePublisherPromise;
|
|
auto releasePublisher = releasePublisherPromise.get_future().share();
|
|
std::atomic<bool> publisherSignalled{false};
|
|
std::atomic<bool> drainerSignalled{false};
|
|
serviceManager->testingSetMemorySyncDeleteOutboxObserver([&](std::string_view stage) {
|
|
if (stage == "delete_publish_locked" && !publisherSignalled.exchange(true)) {
|
|
publisherLockedPromise.set_value();
|
|
releasePublisher.wait();
|
|
} else if (stage == "delete_drain_waiting" && !drainerSignalled.exchange(true)) {
|
|
drainerWaitingPromise.set_value();
|
|
}
|
|
});
|
|
auto deleteCall = std::async(std::launch::async, [&] {
|
|
return yams::cli::run_sync(client.call<yams::daemon::DeleteRequest>(deleteRequest), 10s);
|
|
});
|
|
const auto publisherStatus = publisherLocked.wait_for(5s);
|
|
if (publisherStatus != std::future_status::ready) {
|
|
releasePublisherPromise.set_value();
|
|
FAIL("delete publisher did not reach the serialized outbox section");
|
|
}
|
|
auto competingDrainer = std::async(
|
|
std::launch::async, [serviceManager] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
const auto drainerStatus = drainerWaiting.wait_for(5s);
|
|
releasePublisherPromise.set_value();
|
|
REQUIRE(drainerStatus == std::future_status::ready);
|
|
auto deleted = deleteCall.get();
|
|
competingDrainer.get();
|
|
serviceManager->testingSetMemorySyncDeleteOutboxObserver({});
|
|
REQUIRE(deleted.has_value());
|
|
CHECK(deleted.value().successCount == 1);
|
|
auto pendingErases = daemonSync->pendingErases();
|
|
REQUIRE(pendingErases.has_value());
|
|
CHECK(pendingErases.value().empty());
|
|
auto firstPublisher = std::async(std::launch::async, [serviceManager, hash] {
|
|
return serviceManager->publishMemorySyncDocumentDelete(hash);
|
|
});
|
|
auto secondPublisher = std::async(std::launch::async, [serviceManager, hash] {
|
|
return serviceManager->publishMemorySyncDocumentDelete(hash);
|
|
});
|
|
CHECK(firstPublisher.get().has_value());
|
|
CHECK(secondPublisher.get().has_value());
|
|
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached(documentKey).has_value());
|
|
CHECK_FALSE(peer.readCached(blobKey).has_value());
|
|
CHECK(peer.currentVersion().get("123e4567-e89b-42d3-a456-426614174020") ==
|
|
writerCounterBeforeDelete + 2);
|
|
|
|
// --keep-refs carries its retain-content mode through durable staging and publication. Its
|
|
// document-only completion remains idempotent without inventing a content tombstone.
|
|
const auto retainedPayload = bytes("retained-production-content");
|
|
auto retainedStored = contentStore->storeBytes(retainedPayload);
|
|
REQUIRE(retainedStored.has_value());
|
|
const auto& retainedHash = retainedStored.value().contentHash;
|
|
yams::metadata::BatchDocumentInsert retainedInsert;
|
|
retainedInsert.info.filePath = "/local/corpus/retained.md";
|
|
retainedInsert.info.fileName = "retained.md";
|
|
retainedInsert.info.fileExtension = ".md";
|
|
retainedInsert.info.fileSize = static_cast<std::int64_t>(retainedPayload.size());
|
|
retainedInsert.info.sha256Hash = retainedHash;
|
|
retainedInsert.info.mimeType = "text/markdown";
|
|
std::vector<yams::metadata::BatchDocumentInsert> retainedInserts;
|
|
retainedInserts.push_back(std::move(retainedInsert));
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(retainedInserts).has_value());
|
|
|
|
record.documentId = retainedHash;
|
|
record.contentHash = retainedHash;
|
|
record.filePath = "/local/corpus/retained.md";
|
|
record.fileName = "retained.md";
|
|
record.fileSize = static_cast<std::int64_t>(retainedPayload.size());
|
|
const std::string retainedDocumentKey = "document/" + retainedHash;
|
|
const std::string retainedBlobKey = "content-blob/" + retainedHash;
|
|
REQUIRE(
|
|
daemonSync->publish(retainedDocumentKey, bytes(nlohmann::json(record).dump())).has_value());
|
|
REQUIRE(daemonSync->publish(retainedBlobKey, retainedPayload).has_value());
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
const auto writerCounterBeforeRetainedDelete =
|
|
peer.currentVersion().get("123e4567-e89b-42d3-a456-426614174020");
|
|
|
|
yams::daemon::DeleteRequest retainDeleteRequest;
|
|
retainDeleteRequest.hash = retainedHash;
|
|
retainDeleteRequest.keepRefs = true;
|
|
auto retainedDelete =
|
|
yams::cli::run_sync(client.call<yams::daemon::DeleteRequest>(retainDeleteRequest), 10s);
|
|
REQUIRE(retainedDelete.has_value());
|
|
CHECK(retainedDelete.value().successCount == 1);
|
|
auto retainedLocally = contentStore->exists(retainedHash);
|
|
REQUIRE(retainedLocally.has_value());
|
|
CHECK(retainedLocally.value());
|
|
REQUIRE(serviceManager->publishMemorySyncDocumentDelete(retainedHash, true).has_value());
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached(retainedDocumentKey).has_value());
|
|
CHECK(peer.readCached(retainedBlobKey).has_value());
|
|
CHECK(peer.currentVersion().get("123e4567-e89b-42d3-a456-426614174020") ==
|
|
writerCounterBeforeRetainedDelete + 1);
|
|
|
|
// If local deletion is interrupted after removing bytes but before removing metadata, drain
|
|
// only the ready blob tombstone. The document intent must survive for a later metadata repair.
|
|
const auto interruptedPayload = bytes("interrupted-production-delete");
|
|
auto interruptedStored = contentStore->storeBytes(interruptedPayload);
|
|
REQUIRE(interruptedStored.has_value());
|
|
const auto& interruptedHash = interruptedStored.value().contentHash;
|
|
yams::metadata::BatchDocumentInsert interruptedInsert;
|
|
interruptedInsert.info.filePath = "/local/corpus/interrupted.md";
|
|
interruptedInsert.info.fileName = "interrupted.md";
|
|
interruptedInsert.info.fileExtension = ".md";
|
|
interruptedInsert.info.fileSize = static_cast<std::int64_t>(interruptedPayload.size());
|
|
interruptedInsert.info.sha256Hash = interruptedHash;
|
|
interruptedInsert.info.mimeType = "text/markdown";
|
|
std::vector<yams::metadata::BatchDocumentInsert> interruptedInserts;
|
|
interruptedInserts.push_back(std::move(interruptedInsert));
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(interruptedInserts).has_value());
|
|
|
|
record.documentId = interruptedHash;
|
|
record.contentHash = interruptedHash;
|
|
record.filePath = "/local/corpus/interrupted.md";
|
|
record.fileName = "interrupted.md";
|
|
record.fileSize = static_cast<std::int64_t>(interruptedPayload.size());
|
|
const std::string interruptedDocumentKey = "document/" + interruptedHash;
|
|
const std::string interruptedBlobKey = "content-blob/" + interruptedHash;
|
|
REQUIRE(daemonSync->publish(interruptedDocumentKey, bytes(nlohmann::json(record).dump()))
|
|
.has_value());
|
|
REQUIRE(daemonSync->publish(interruptedBlobKey, interruptedPayload).has_value());
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
REQUIRE(peer.readCached(interruptedDocumentKey).has_value());
|
|
REQUIRE(peer.readCached(interruptedBlobKey).has_value());
|
|
|
|
REQUIRE(serviceManager->stageMemorySyncDocumentDelete(interruptedHash).has_value());
|
|
auto removedBytes = contentStore->remove(interruptedHash);
|
|
REQUIRE(removedBytes.has_value());
|
|
REQUIRE(removedBytes.value());
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK(peer.readCached(interruptedDocumentKey).has_value());
|
|
CHECK_FALSE(peer.readCached(interruptedBlobKey).has_value());
|
|
pendingErases = daemonSync->pendingErases();
|
|
REQUIRE(pendingErases.has_value());
|
|
CHECK(std::ranges::any_of(pendingErases.value(), [&](const auto& intent) {
|
|
return intent.logicalKey == interruptedDocumentKey;
|
|
}));
|
|
CHECK_FALSE(std::ranges::any_of(pendingErases.value(), [&](const auto& intent) {
|
|
return intent.logicalKey == interruptedBlobKey;
|
|
}));
|
|
|
|
auto interruptedDocument = repository->getDocumentByHash(interruptedHash);
|
|
REQUIRE(interruptedDocument.has_value());
|
|
REQUIRE(interruptedDocument.value().has_value());
|
|
REQUIRE(repository->deleteDocument(interruptedDocument.value()->id).has_value());
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached(interruptedDocumentKey).has_value());
|
|
CHECK(daemonSync->pendingErases().value().empty());
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync stop during production apply prevents later adapters",
|
|
"[integration][daemon][memory-sync][lifecycle]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174010",
|
|
"apply-cancellation-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"apply-peer", 60'000, "apply-cancellation-corpus", 1}};
|
|
const auto payload = bytes("apply-cancellation-content");
|
|
const auto hash = digest(payload);
|
|
REQUIRE(peer.publish("content-blob/" + hash, payload).has_value());
|
|
|
|
yams::memory_sync::MetadataDocumentRecord record;
|
|
record.documentId = hash;
|
|
record.contentHash = hash;
|
|
record.filePath = "/peer/apply-cancelled.md";
|
|
record.fileName = "apply-cancelled.md";
|
|
record.fileExtension = ".md";
|
|
record.fileSize = static_cast<std::int64_t>(payload.size());
|
|
record.mimeType = "text/markdown";
|
|
REQUIRE(peer.publish("document/" + hash, bytes(nlohmann::json(record).dump())).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
|
|
std::atomic<bool> afterContent{false};
|
|
std::atomic<bool> afterMetadata{false};
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
serviceManager->testingDisableMemorySyncApplySkip();
|
|
StopFromStage stopFromStage;
|
|
serviceManager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (stage == "apply.after_content") {
|
|
afterContent.store(true, std::memory_order_release);
|
|
stopFromStage.request(*daemonSync);
|
|
} else if (stage == "apply.after_metadata") {
|
|
afterMetadata.store(true, std::memory_order_release);
|
|
}
|
|
});
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
stopFromStage.join();
|
|
|
|
CHECK(afterContent.load(std::memory_order_acquire));
|
|
CHECK_FALSE(afterMetadata.load(std::memory_order_acquire));
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(contentStore != nullptr);
|
|
const auto contentExists = contentStore->exists(hash);
|
|
REQUIRE(contentExists.has_value());
|
|
CHECK(contentExists.value());
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
const auto document = repository->getDocumentByHash(hash);
|
|
REQUIRE(document.has_value());
|
|
CHECK_FALSE(document.value().has_value());
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon serializes concurrent memory sync apply callbacks",
|
|
"[integration][daemon][memory-sync][apply-serialization]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174021",
|
|
"apply-serialization-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
std::atomic<std::size_t> contentStages{0};
|
|
std::promise<void> firstEnteredPromise;
|
|
auto firstEntered = firstEnteredPromise.get_future();
|
|
std::promise<void> secondEnteredPromise;
|
|
auto secondEntered = secondEnteredPromise.get_future();
|
|
std::promise<void> releaseFirstPromise;
|
|
auto releaseFirst = releaseFirstPromise.get_future().share();
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
serviceManager->testingDisableMemorySyncApplySkip();
|
|
serviceManager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (stage != "apply.after_content" || !tlsTestStageCaller) {
|
|
return;
|
|
}
|
|
if (contentStages.fetch_add(1, std::memory_order_acq_rel) == 0) {
|
|
firstEnteredPromise.set_value();
|
|
releaseFirst.wait();
|
|
} else {
|
|
secondEnteredPromise.set_value();
|
|
}
|
|
});
|
|
|
|
const auto applyAttemptsBefore = serviceManager->testingMemorySyncApplyAttempts();
|
|
auto first = std::async(std::launch::async, [serviceManager] {
|
|
asTestStageCaller([serviceManager] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
});
|
|
REQUIRE(firstEntered.wait_for(5s) == std::future_status::ready);
|
|
CHECK(serviceManager->testingMemorySyncApplyLockHeld());
|
|
std::atomic<bool> secondCalling{false};
|
|
auto second = std::async(std::launch::async, [serviceManager, &secondCalling] {
|
|
secondCalling.store(true, std::memory_order_release);
|
|
asTestStageCaller([serviceManager] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
});
|
|
// A worker cycle may add an attempt of its own, so wait for at least both test calls.
|
|
const auto secondApplyDeadline = std::chrono::steady_clock::now() + 5s;
|
|
while ((!secondCalling.load(std::memory_order_acquire) ||
|
|
serviceManager->testingMemorySyncApplyAttempts() < applyAttemptsBefore + 2) &&
|
|
std::chrono::steady_clock::now() < secondApplyDeadline) {
|
|
std::this_thread::yield();
|
|
}
|
|
REQUIRE(secondCalling.load(std::memory_order_acquire));
|
|
REQUIRE(serviceManager->testingMemorySyncApplyAttempts() >= applyAttemptsBefore + 2);
|
|
CHECK(contentStages.load(std::memory_order_acquire) == 1);
|
|
|
|
releaseFirstPromise.set_value();
|
|
first.get();
|
|
second.get();
|
|
CHECK(secondEntered.wait_for(5s) == std::future_status::ready);
|
|
CHECK(contentStages.load(std::memory_order_acquire) == 2);
|
|
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(repository != nullptr);
|
|
REQUIRE(contentStore != nullptr);
|
|
std::vector<std::string> hashes;
|
|
std::vector<yams::metadata::BatchDocumentInsert> inserts;
|
|
for (int index = 0; index < 2; ++index) {
|
|
const auto payload = bytes("serialized-backfill-" + std::to_string(index));
|
|
auto stored = contentStore->storeBytes(payload);
|
|
REQUIRE(stored.has_value());
|
|
hashes.push_back(stored.value().contentHash);
|
|
yams::metadata::BatchDocumentInsert item;
|
|
item.info.filePath = "/local/serialized-" + std::to_string(index) + ".md";
|
|
item.info.fileName = "serialized-" + std::to_string(index) + ".md";
|
|
item.info.fileExtension = ".md";
|
|
item.info.fileSize = static_cast<std::int64_t>(payload.size());
|
|
item.info.sha256Hash = stored.value().contentHash;
|
|
item.info.mimeType = "text/markdown";
|
|
inserts.push_back(std::move(item));
|
|
}
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(inserts).has_value());
|
|
const auto writerCounterBeforeBackfill =
|
|
daemonSync->currentVersion().get("123e4567-e89b-42d3-a456-426614174021");
|
|
serviceManager->testingSetMemorySyncBackfillItemBudget(1);
|
|
|
|
std::atomic<std::size_t> backfillStages{0};
|
|
std::promise<void> firstBackfillEnteredPromise;
|
|
auto firstBackfillEntered = firstBackfillEnteredPromise.get_future();
|
|
std::promise<void> secondBackfillEnteredPromise;
|
|
auto secondBackfillEntered = secondBackfillEnteredPromise.get_future();
|
|
std::promise<void> releaseFirstBackfillPromise;
|
|
auto releaseFirstBackfill = releaseFirstBackfillPromise.get_future().share();
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
serviceManager->testingDisableMemorySyncApplySkip();
|
|
serviceManager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (!stage.starts_with("backfill.") || !tlsTestStageCaller) {
|
|
return;
|
|
}
|
|
if (backfillStages.fetch_add(1, std::memory_order_acq_rel) == 0) {
|
|
firstBackfillEnteredPromise.set_value();
|
|
releaseFirstBackfill.wait();
|
|
} else {
|
|
secondBackfillEnteredPromise.set_value();
|
|
}
|
|
});
|
|
const auto backfillAttemptsBefore = serviceManager->testingMemorySyncBackfillAttempts();
|
|
auto firstBackfill = std::async(std::launch::async, [serviceManager] {
|
|
asTestStageCaller([serviceManager] { serviceManager->testingPublishMemorySyncBackfill(); });
|
|
});
|
|
REQUIRE(firstBackfillEntered.wait_for(5s) == std::future_status::ready);
|
|
CHECK(serviceManager->testingMemorySyncBackfillLockHeld());
|
|
auto secondBackfill = std::async(std::launch::async, [serviceManager] {
|
|
asTestStageCaller([serviceManager] { serviceManager->testingPublishMemorySyncBackfill(); });
|
|
});
|
|
const auto secondBackfillDeadline = std::chrono::steady_clock::now() + 5s;
|
|
while (serviceManager->testingMemorySyncBackfillAttempts() < backfillAttemptsBefore + 2 &&
|
|
std::chrono::steady_clock::now() < secondBackfillDeadline) {
|
|
std::this_thread::yield();
|
|
}
|
|
REQUIRE(serviceManager->testingMemorySyncBackfillAttempts() == backfillAttemptsBefore + 2);
|
|
CHECK(backfillStages.load(std::memory_order_acquire) == 1);
|
|
|
|
releaseFirstBackfillPromise.set_value();
|
|
firstBackfill.get();
|
|
secondBackfill.get();
|
|
CHECK(secondBackfillEntered.wait_for(5s) == std::future_status::ready);
|
|
CHECK(backfillStages.load(std::memory_order_acquire) == 2);
|
|
serviceManager->testingSetMemorySyncStageObserver({});
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"serialization-peer", 60'000,
|
|
"apply-serialization-corpus", 1}};
|
|
REQUIRE(peer.syncFully().has_value());
|
|
for (const auto& hash : hashes) {
|
|
CHECK(peer.readCached("document/" + hash).has_value());
|
|
}
|
|
CHECK(peer.currentVersion().get("123e4567-e89b-42d3-a456-426614174021") ==
|
|
writerCounterBeforeBackfill + 4);
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon topology status keeps exact deferred totals and retries local prerequisites",
|
|
"[integration][daemon][memory-sync][deferred-total]") {
|
|
ScopedHostPressureSample hostPressure;
|
|
yams::test::SpdlogLevelGuard logGuard;
|
|
spdlog::set_level(spdlog::level::warn);
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174022",
|
|
"apply-deferred-total-corpus");
|
|
options.isolateEnvironment = true;
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
using yams::memory_sync::ApplyStage;
|
|
const auto applyHealth = [&] {
|
|
auto status = serviceManager->getMemorySyncStatus();
|
|
REQUIRE(status.has_value());
|
|
return status.value().apply;
|
|
};
|
|
|
|
// Clear before publishing into a separate peer backend, invisible to the daemon's scan.
|
|
// The first actual inbound merge dispatches through the callback execution lock, draining
|
|
// any callback copied before this clear; later dispatches find the cleared callback.
|
|
daemonSync->setAfterSyncCallback({});
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "deferred-total-peer-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"deferred-total-peer", 60'000,
|
|
"apply-deferred-total-corpus", 1}};
|
|
constexpr std::size_t kEdgeCount = 70;
|
|
for (std::size_t i = 0; i < kEdgeCount; ++i) {
|
|
yams::memory_sync::TopologyEdgeRecord record;
|
|
record.sourceNodeKey = "status:source";
|
|
record.relation = "relation-" + std::to_string(i);
|
|
record.targetNodeKey = "status:target";
|
|
record.weight = 1.0;
|
|
const auto published =
|
|
peer.publish("topology-edge/" + record.id(), bytes(nlohmann::json(record).dump()));
|
|
INFO((published ? std::string{} : published.error().message));
|
|
REQUIRE(published.has_value());
|
|
}
|
|
const auto batch = peer.exportLocalDeltasAfter({}, 128);
|
|
REQUIRE(batch.has_value());
|
|
REQUIRE_FALSE(batch.value().hasMore);
|
|
const auto received = daemonSync->applyDeltas(batch.value().deltas);
|
|
INFO((received ? std::string{} : received.error().message));
|
|
REQUIRE(received.has_value());
|
|
REQUIRE(received.value().merged == kEdgeCount);
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
const auto winners = daemonSync->readCommittedWinnersAfter("topology-edge/", "", 128);
|
|
REQUIRE(winners.has_value());
|
|
REQUIRE(winners.value().size() == kEdgeCount);
|
|
yams::daemon::ClientConfig clientConfig;
|
|
clientConfig.socketPath = harness.socketPath();
|
|
clientConfig.connectTimeout = 5s;
|
|
clientConfig.requestTimeout = 10s;
|
|
clientConfig.autoStart = false;
|
|
yams::daemon::DaemonClient client{clientConfig};
|
|
REQUIRE(yams::cli::run_sync(client.connect(), 5s).has_value());
|
|
const auto ipcStatus = [&] {
|
|
yams::daemon::MemorySyncRequest request;
|
|
request.operation = yams::daemon::MemorySyncOperation::Status;
|
|
return yams::cli::run_sync(client.call<yams::daemon::MemorySyncRequest>(request), 10s);
|
|
};
|
|
for (int pass = 0; pass < 2; ++pass) {
|
|
const auto cyclesBefore = applyHealth().applyCycles;
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
const auto health = applyHealth();
|
|
CHECK(health.applyCycles == cyclesBefore + 1);
|
|
CHECK(health.deferredIn(ApplyStage::Topology) == kEdgeCount);
|
|
CHECK(health.deferredTotal() == kEdgeCount);
|
|
CHECK(health.failuresIn(ApplyStage::Topology) == 0);
|
|
CHECK(health.applyFailedCycles == 0);
|
|
const auto status = ipcStatus();
|
|
REQUIRE(status.has_value());
|
|
CHECK(status.value().deferredTopology == kEdgeCount);
|
|
CHECK(status.value().applyFailedCycles == 0);
|
|
}
|
|
|
|
// Only the local prerequisite changes: no peer node payload or new replicated winner is
|
|
// published before retry. The same edge winners must now apply, not be idle-suppressed.
|
|
auto kg = serviceManager->getKgStore();
|
|
REQUIRE(kg != nullptr);
|
|
yams::metadata::KGNode source;
|
|
source.nodeKey = "status:source";
|
|
source.type = "entity";
|
|
const auto sourceId = kg->upsertNode(source);
|
|
REQUIRE(sourceId.has_value());
|
|
yams::metadata::KGNode target;
|
|
target.nodeKey = "status:target";
|
|
target.type = "entity";
|
|
const auto targetId = kg->upsertNode(target);
|
|
REQUIRE(targetId.has_value());
|
|
const auto beforeRetry = daemonSync->readCommittedWinnersAfter("topology-edge/", "", 128);
|
|
REQUIRE(beforeRetry.has_value());
|
|
CHECK(beforeRetry.value().size() == kEdgeCount);
|
|
for (int pass = 0; pass < 2; ++pass) {
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
const auto health = applyHealth();
|
|
CHECK(health.deferredIn(ApplyStage::Topology) == 0);
|
|
CHECK(health.deferredTotal() == 0);
|
|
CHECK(health.failuresIn(ApplyStage::Topology) == 0);
|
|
CHECK(health.applyFailedCycles == 0);
|
|
std::size_t edgeCount = 0;
|
|
for (std::size_t i = 0; i < kEdgeCount; ++i) {
|
|
const auto edges = kg->getEdgesBetween(sourceId.value(), targetId.value(),
|
|
"relation-" + std::to_string(i));
|
|
REQUIRE(edges.has_value());
|
|
CHECK(edges.value().size() == 1);
|
|
edgeCount += edges.value().size();
|
|
}
|
|
CHECK(edgeCount == kEdgeCount);
|
|
const auto status = ipcStatus();
|
|
REQUIRE(status.has_value());
|
|
CHECK(status.value().deferredTopology == 0);
|
|
CHECK(status.value().applyFailedCycles == 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync IPC reads off-resident authenticated winners",
|
|
"[integration][daemon][memory-sync][offresident-read]") {
|
|
ScopedHostPressureSample hostPressure;
|
|
yams::test::SpdlogLevelGuard logGuard;
|
|
spdlog::set_level(spdlog::level::warn);
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174024",
|
|
"offresident-read-corpus");
|
|
auto configure = std::move(options.configureDaemon);
|
|
options.configureDaemon = [configure =
|
|
std::move(configure)](yams::daemon::DaemonConfig& config) {
|
|
configure(config);
|
|
auto keys = yams::memory_sync::generateWriterKeyPair();
|
|
REQUIRE(keys.has_value());
|
|
const auto directory = config.dataDir / "read-auth";
|
|
std::filesystem::create_directories(directory);
|
|
const auto privateKey = directory / "writer-private.pem";
|
|
const auto publicKey = directory / "writer-public.pem";
|
|
const auto manifest = directory / "writers.json";
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(
|
|
privateKey, keys.value().privateKeyPem)
|
|
.has_value());
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(
|
|
publicKey, keys.value().publicKeyPem)
|
|
.has_value());
|
|
const nlohmann::json content{
|
|
{"schema_version", 1},
|
|
{"corpus_id", config.memorySync.corpusId},
|
|
{"corpus_epoch", config.memorySync.corpusEpoch},
|
|
{"local_key",
|
|
{{"writer_id", config.memorySync.nodeId},
|
|
{"key_id", "cold-read-v1"},
|
|
{"private_key_path", privateKey.string()}}},
|
|
{"trusted_writers",
|
|
nlohmann::json::array({{{"writer_id", config.memorySync.nodeId},
|
|
{"key_id", "cold-read-v1"},
|
|
{"public_key_path", publicKey.string()}}})}};
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(manifest,
|
|
content.dump())
|
|
.has_value());
|
|
// The listener parser requires a real port; reserve an OS-selected local endpoint
|
|
// briefly instead of using the live daemon's default listener or weakening parsing.
|
|
boost::asio::io_context io;
|
|
boost::asio::ip::tcp::acceptor reserved{io, {boost::asio::ip::address_v4::loopback(), 0}};
|
|
const auto port = reserved.local_endpoint().port();
|
|
reserved.close();
|
|
config.memorySync.transport = "direct";
|
|
config.memorySync.listen = "127.0.0.1:" + std::to_string(port);
|
|
config.memorySync.writerAuthRequired = true;
|
|
config.memorySync.writerAuthManifestPath = manifest.string();
|
|
config.memorySync.limits.maxCacheBytes = 8;
|
|
};
|
|
options.isolateEnvironment = true;
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
auto* manager = harness.daemon()->getServiceManager();
|
|
REQUIRE(manager != nullptr);
|
|
auto* sync = manager->testingMemorySyncService();
|
|
REQUIRE(sync != nullptr);
|
|
REQUIRE(manager->getWorkCoordinator() != nullptr);
|
|
sync->setAfterSyncCallback({});
|
|
|
|
yams::daemon::ClientConfig clientConfig;
|
|
clientConfig.socketPath = harness.socketPath();
|
|
clientConfig.connectTimeout = 5s;
|
|
clientConfig.requestTimeout = 10s;
|
|
clientConfig.autoStart = false;
|
|
yams::daemon::DaemonClient client{clientConfig};
|
|
REQUIRE(yams::cli::run_sync(client.connect(), 5s).has_value());
|
|
const auto call = [&](yams::daemon::MemorySyncOperation operation, const std::string& key,
|
|
const std::string& value = {}) {
|
|
yams::daemon::MemorySyncRequest request{operation, key, value};
|
|
return yams::cli::run_sync(client.call<yams::daemon::MemorySyncRequest>(request), 10s);
|
|
};
|
|
const auto first = call(yams::daemon::MemorySyncOperation::Publish, "read-a", "aaaaaaaa");
|
|
INFO((first ? std::string{} : first.error().message));
|
|
REQUIRE(first.has_value());
|
|
const auto second = call(yams::daemon::MemorySyncOperation::Publish, "read-b", "bbbbbbbb");
|
|
INFO((second ? std::string{} : second.error().message));
|
|
REQUIRE(second.has_value());
|
|
const auto warmA = manager->readMemorySyncCached("read-a");
|
|
const std::string cold = warmA ? "read-b" : "read-a";
|
|
const auto cached = manager->readMemorySyncCached(cold);
|
|
REQUIRE_FALSE(cached.has_value());
|
|
REQUIRE(cached.error().code == yams::ErrorCode::NotFound);
|
|
const auto read = call(yams::daemon::MemorySyncOperation::Read, cold);
|
|
INFO((read ? std::string{} : read.error().message));
|
|
CHECK(read.has_value());
|
|
if (read) {
|
|
CHECK(read.value().value == (cold == "read-a" ? "aaaaaaaa" : "bbbbbbbb"));
|
|
}
|
|
hostPressure.setBusy();
|
|
// Admission to explicit I/O may defer, but cache-only consumers must not start
|
|
// a recovery scan, enter the worker queue, or wait for local pressure to clear.
|
|
const auto cachedDuringPressure = manager->readMemorySyncCached(warmA ? "read-a" : "read-b");
|
|
CHECK(cachedDuringPressure.has_value());
|
|
const auto deferred = call(yams::daemon::MemorySyncOperation::Read, cold);
|
|
CHECK_FALSE(deferred.has_value());
|
|
if (!deferred) {
|
|
CHECK(deferred.error().code == yams::ErrorCode::ResourceBusy);
|
|
}
|
|
hostPressure.setCalm();
|
|
const auto missing = call(yams::daemon::MemorySyncOperation::Read, "never-published");
|
|
REQUIRE_FALSE(missing.has_value());
|
|
CHECK(missing.error().code == yams::ErrorCode::NotFound);
|
|
}
|
|
|
|
TEST_CASE("MCP get serves authenticated replicated cold documents after content prerequisites",
|
|
"[integration][daemon][memory-sync][mcp][replicated-cold-document]") {
|
|
ScopedHostPressureSample hostPressure;
|
|
yams::test::SpdlogLevelGuard logGuard;
|
|
spdlog::set_level(spdlog::level::warn);
|
|
std::vector<std::string> stages;
|
|
const std::string localId = "123e4567-e89b-42d3-a456-426614174025";
|
|
const std::string peerId = "123e4567-e89b-42d3-a456-426614174026";
|
|
const std::string corpus = "authenticated-mcp-cold-corpus";
|
|
constexpr std::size_t kCacheBytes = 4096;
|
|
auto localKeys = yams::memory_sync::generateWriterKeyPair();
|
|
auto peerKeys = yams::memory_sync::generateWriterKeyPair();
|
|
REQUIRE(localKeys.has_value());
|
|
REQUIRE(peerKeys.has_value());
|
|
yams::memory_sync::WriterAuthConfig peerTrust;
|
|
peerTrust.required = true;
|
|
peerTrust.localWriterId = peerId;
|
|
peerTrust.localKeyId = "peer-v1";
|
|
peerTrust.localPrivateKeyPem = peerKeys.value().privateKeyPem;
|
|
peerTrust.trustedKeys = {{localId, "local-v1", localKeys.value().publicKeyPem, false},
|
|
{peerId, "peer-v1", peerKeys.value().publicKeyPem, false}};
|
|
auto peerAuth = yams::memory_sync::WriterAuthenticator::create(std::move(peerTrust), corpus, 1);
|
|
REQUIRE(peerAuth.has_value());
|
|
REQUIRE(peerAuth.value()->required());
|
|
|
|
auto options = makeMemorySyncHarnessOptions(localId, corpus);
|
|
auto configure = std::move(options.configureDaemon);
|
|
options.configureDaemon = [configure = std::move(configure), localKey = localKeys.value(),
|
|
peerPublic = peerKeys.value().publicKeyPem, peerId,
|
|
cacheBytes = kCacheBytes](yams::daemon::DaemonConfig& config) {
|
|
configure(config);
|
|
const auto directory = config.dataDir / "mcp-read-auth";
|
|
std::filesystem::create_directories(directory);
|
|
const auto privateKey = directory / "local-private.pem";
|
|
const auto publicKey = directory / "local-public.pem";
|
|
const auto peerKey = directory / "peer-public.pem";
|
|
const auto manifest = directory / "writers.json";
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(
|
|
privateKey, localKey.privateKeyPem)
|
|
.has_value());
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(
|
|
publicKey, localKey.publicKeyPem)
|
|
.has_value());
|
|
REQUIRE(
|
|
yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(peerKey, peerPublic)
|
|
.has_value());
|
|
const nlohmann::json content{
|
|
{"schema_version", 1},
|
|
{"corpus_id", config.memorySync.corpusId},
|
|
{"corpus_epoch", config.memorySync.corpusEpoch},
|
|
{"local_key",
|
|
{{"writer_id", config.memorySync.nodeId},
|
|
{"key_id", "local-v1"},
|
|
{"private_key_path", privateKey.string()}}},
|
|
{"trusted_writers", nlohmann::json::array({{{"writer_id", config.memorySync.nodeId},
|
|
{"key_id", "local-v1"},
|
|
{"public_key_path", publicKey.string()}},
|
|
{{"writer_id", peerId},
|
|
{"key_id", "peer-v1"},
|
|
{"public_key_path", peerKey.string()}}})}};
|
|
REQUIRE(yams::daemon::ServiceManager::__test_writeProtectedP2pPrivateKey(manifest,
|
|
content.dump())
|
|
.has_value());
|
|
boost::asio::io_context io;
|
|
boost::asio::ip::tcp::acceptor reserved{io, {boost::asio::ip::address_v4::loopback(), 0}};
|
|
const auto port = reserved.local_endpoint().port();
|
|
reserved.close();
|
|
config.memorySync.transport = "direct";
|
|
config.memorySync.listen = "127.0.0.1:" + std::to_string(port);
|
|
config.memorySync.writerAuthRequired = true;
|
|
config.memorySync.writerAuthManifestPath = manifest.string();
|
|
config.memorySync.limits.maxCacheBytes = cacheBytes;
|
|
};
|
|
options.isolateEnvironment = true;
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
auto* manager = harness.daemon()->getServiceManager();
|
|
REQUIRE(manager != nullptr);
|
|
auto* sync = manager->testingMemorySyncService();
|
|
REQUIRE(sync != nullptr);
|
|
auto repository = manager->getMetadataRepo();
|
|
auto contentStore = manager->getContentStore();
|
|
REQUIRE(repository != nullptr);
|
|
REQUIRE(contentStore != nullptr);
|
|
sync->setAfterSyncCallback({});
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
manager->testingDisableMemorySyncApplySkip();
|
|
manager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (tlsTestStageCaller) {
|
|
stages.emplace_back(stage);
|
|
}
|
|
});
|
|
|
|
yams::memory_sync::MemorySyncConfig peerConfig{peerId, 60'000, corpus, 1};
|
|
peerConfig.writerAuth = peerAuth.value();
|
|
peerConfig.exclusiveBackend = true;
|
|
peerConfig.limits.maxCacheBytes = kCacheBytes;
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "authenticated-mcp-peer"), peerConfig};
|
|
const std::string text = "Authenticated replicated document served through real MCP get.";
|
|
const auto payload = bytes(text);
|
|
const auto hash = digest(payload);
|
|
const auto documentKey = "document/" + hash;
|
|
const auto blobKey = "content-blob/" + hash;
|
|
yams::memory_sync::MetadataDocumentRecord record;
|
|
record.documentId = hash;
|
|
record.contentHash = hash;
|
|
record.filePath = "/peer/replicated-cold.txt";
|
|
record.fileName = "replicated-cold.txt";
|
|
record.fileExtension = ".txt";
|
|
record.fileSize = static_cast<std::int64_t>(payload.size());
|
|
record.mimeType = "text/plain";
|
|
REQUIRE(peer.publish(documentKey, bytes(nlohmann::json(record).dump())).has_value());
|
|
auto metadata = peer.exportLocalDeltasAfter({});
|
|
REQUIRE(metadata.has_value());
|
|
REQUIRE(metadata.value().deltas.size() == 1);
|
|
auto first = sync->applyDeltas(metadata.value().deltas);
|
|
REQUIRE(first.has_value());
|
|
REQUIRE(sync->publish("user/mcp-cache-fill", bytes(std::string(kCacheBytes, 'a'))).has_value());
|
|
REQUIRE_FALSE(sync->readCached(documentKey).has_value());
|
|
asTestStageCaller([&] { manager->testingApplyMemorySyncWinners(); });
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
auto beforeContent = repository->getDocumentByHash(hash);
|
|
REQUIRE(beforeContent.has_value());
|
|
CHECK_FALSE(beforeContent.value().has_value());
|
|
auto absent = contentStore->exists(hash);
|
|
REQUIRE(absent.has_value());
|
|
CHECK_FALSE(absent.value());
|
|
|
|
// Exercise the supported in-process HTTP bridge to the actual MCP registry and
|
|
// get handler. Only the unused framing transport is inert: daemon IPC/CAS are real.
|
|
class InProcessTransport final : public yams::mcp::ITransport {
|
|
public:
|
|
void send(const nlohmann::json&) override {}
|
|
yams::mcp::MessageResult receive() override {
|
|
return yams::Error{yams::ErrorCode::NotImplemented, "HTTP bridge has no framing loop"};
|
|
}
|
|
bool isConnected() const override { return false; }
|
|
void close() override {}
|
|
yams::mcp::TransportState getState() const override {
|
|
return yams::mcp::TransportState::Disconnected;
|
|
}
|
|
};
|
|
auto server = std::make_shared<yams::mcp::MCPServer>(std::make_unique<InProcessTransport>(),
|
|
nullptr, harness.socketPath());
|
|
const nlohmann::json get{{"hash", hash}, {"include_content", true}};
|
|
const auto missing = server->callToolPublic("get", get);
|
|
INFO(missing.dump());
|
|
CHECK((missing.value("isError", false) || missing.contains("error")));
|
|
|
|
// Only the missing content arrives: the deferred metadata envelope is unchanged.
|
|
REQUIRE(peer.publish(blobKey, payload).has_value());
|
|
auto content = peer.exportLocalDeltasAfter(first.value().version);
|
|
REQUIRE(content.has_value());
|
|
REQUIRE(content.value().deltas.size() == 1);
|
|
REQUIRE(sync->applyDeltas(content.value().deltas).has_value());
|
|
REQUIRE(sync->publish("user/mcp-cache-fill", bytes(std::string(kCacheBytes, 'b'))).has_value());
|
|
REQUIRE_FALSE(sync->readCached(documentKey).has_value());
|
|
REQUIRE_FALSE(sync->readCached(blobKey).has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { manager->testingApplyMemorySyncWinners(); });
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
auto imported = repository->getDocumentByHash(hash);
|
|
REQUIRE(imported.has_value());
|
|
REQUIRE(imported.value().has_value());
|
|
CHECK(imported.value()->sha256Hash == hash);
|
|
auto stored = contentStore->retrieveBytes(hash);
|
|
REQUIRE(stored.has_value());
|
|
CHECK(stored.value() == payload);
|
|
REQUIRE(sync->publish("user/mcp-cache-fill", bytes(std::string(kCacheBytes, 'c'))).has_value());
|
|
REQUIRE_FALSE(sync->readCached(documentKey).has_value());
|
|
REQUIRE_FALSE(sync->readCached(blobKey).has_value());
|
|
const auto result = server->callToolPublic("get", get);
|
|
INFO(result.dump());
|
|
REQUIRE_FALSE(result.contains("error"));
|
|
REQUIRE_FALSE(result.value("isError", false));
|
|
REQUIRE(result.contains("content"));
|
|
REQUIRE_FALSE(result.at("content").empty());
|
|
const auto response =
|
|
nlohmann::json::parse(result.at("content").at(0).at("text").get<std::string>());
|
|
CHECK(response.at("hash").get<std::string>() == hash);
|
|
CHECK(response.at("content").get<std::string>() == text);
|
|
CHECK_FALSE(response.value("content_truncated", false));
|
|
manager->testingSetMemorySyncStageObserver({});
|
|
server->stop();
|
|
server.reset();
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync pressure defers topology without reporting a hard failure",
|
|
"[integration][daemon][memory-sync][pressure-health]") {
|
|
ScopedHostPressureSample hostPressure;
|
|
yams::test::SpdlogLevelGuard logGuard;
|
|
spdlog::set_level(spdlog::level::warn);
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174023",
|
|
"apply-pressure-health-corpus");
|
|
options.isolateEnvironment = true;
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
auto kg = serviceManager->getKgStore();
|
|
REQUIRE(kg != nullptr);
|
|
const auto applyHealth = [&] {
|
|
const auto status = serviceManager->getMemorySyncStatus();
|
|
REQUIRE(status.has_value());
|
|
return status.value().apply;
|
|
};
|
|
daemonSync->setAfterSyncCallback({});
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "pressure-health-peer-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"pressure-health-peer", 60'000,
|
|
"apply-pressure-health-corpus", 1}};
|
|
const auto receivePeer = [&] {
|
|
const auto batch = peer.exportLocalDeltasAfter({}, 128);
|
|
REQUIRE(batch.has_value());
|
|
REQUIRE_FALSE(batch.value().hasMore);
|
|
const auto received = daemonSync->applyDeltas(batch.value().deltas);
|
|
INFO((received ? std::string{} : received.error().message));
|
|
REQUIRE(received.has_value());
|
|
REQUIRE(received.value().merged == 1);
|
|
};
|
|
yams::memory_sync::TopologyNodeRecord record;
|
|
record.nodeKey = "pressure:node";
|
|
record.type = "symbol";
|
|
const auto nodeKey =
|
|
"topology-node/" + yams::memory_sync::escapeRecordKeySegment(record.nodeKey);
|
|
record.label = "before-pressure";
|
|
REQUIRE(peer.publish(nodeKey, bytes(nlohmann::json(record).dump())).has_value());
|
|
receivePeer();
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
const auto original = kg->getNodeByKey(record.nodeKey);
|
|
REQUIRE(original.has_value());
|
|
REQUIRE(original.value().has_value());
|
|
CHECK(original.value()->label.value_or("") == "before-pressure");
|
|
const auto before = applyHealth();
|
|
REQUIRE(before.applyFailedCycles == 0);
|
|
CHECK(before.scannedAllIn(yams::memory_sync::ApplyStage::Topology));
|
|
|
|
// A second real winner arrives after the first inbound dispatch drained startup work.
|
|
// No automatic callback can apply it ahead of this explicitly controlled busy-host pass.
|
|
record.label = "after-pressure";
|
|
REQUIRE(peer.publish(nodeKey, bytes(nlohmann::json(record).dump())).has_value());
|
|
receivePeer();
|
|
hostPressure.setBusy();
|
|
REQUIRE(yams::daemon::ResourceGovernor::instance().hostPressureElevated());
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
using yams::memory_sync::ApplyStage;
|
|
const auto deferred = applyHealth();
|
|
CHECK_FALSE(deferred.scannedAllIn(ApplyStage::Topology));
|
|
CHECK(deferred.applyFailedCycles == before.applyFailedCycles);
|
|
CHECK(deferred.failuresIn(ApplyStage::Topology) == before.failuresIn(ApplyStage::Topology));
|
|
CHECK(deferred.lastFailureStage == before.lastFailureStage);
|
|
const auto untouched = kg->getNodeByKey(record.nodeKey);
|
|
REQUIRE(untouched.has_value());
|
|
REQUIRE(untouched.value().has_value());
|
|
CHECK(untouched.value()->label.value_or("") == "before-pressure");
|
|
|
|
hostPressure.setCalm();
|
|
REQUIRE_FALSE(yams::daemon::ResourceGovernor::instance().hostPressureElevated());
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
const auto repaired = kg->getNodeByKey(record.nodeKey);
|
|
REQUIRE(repaired.has_value());
|
|
REQUIRE(repaired.value().has_value());
|
|
CHECK(repaired.value()->label.value_or("") == "after-pressure");
|
|
CHECK(applyHealth().scannedAllIn(ApplyStage::Topology));
|
|
CHECK(applyHealth().applyFailedCycles == before.applyFailedCycles);
|
|
CHECK(applyHealth().failuresIn(ApplyStage::Topology) ==
|
|
before.failuresIn(ApplyStage::Topology));
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync stage failures neither skip later stages nor block retry",
|
|
"[integration][daemon][memory-sync][prerequisite]") {
|
|
ScopedHostPressureSample hostPressure;
|
|
yams::test::SpdlogLevelGuard logGuard;
|
|
spdlog::set_level(spdlog::level::warn);
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174012",
|
|
"apply-prerequisite-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"apply-peer", 60'000, "apply-prerequisite-corpus", 1}};
|
|
std::vector<std::string> stages;
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
serviceManager->testingDisableMemorySyncApplySkip();
|
|
serviceManager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (tlsTestStageCaller) {
|
|
stages.emplace_back(stage);
|
|
}
|
|
});
|
|
const auto applyHealth = [&] {
|
|
auto status = serviceManager->getMemorySyncStatus();
|
|
REQUIRE(status.has_value());
|
|
return status.value().apply;
|
|
};
|
|
using yams::memory_sync::ApplyStage;
|
|
|
|
const auto payload = bytes("prerequisite-content");
|
|
const auto hash = digest(payload);
|
|
REQUIRE(peer.publish("content-blob/" + hash, bytes("wrong-content")).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
// A corrupt content blob fails the content stage, yet every later stage still runs.
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
CHECK(applyHealth().failuresIn(ApplyStage::Content) >= 1);
|
|
CHECK(applyHealth().applyFailedCycles >= 1);
|
|
CHECK(applyHealth().lastFailureStage == "content");
|
|
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(contentStore != nullptr);
|
|
REQUIRE(contentStore->exists(hash).has_value());
|
|
CHECK_FALSE(contentStore->exists(hash).value());
|
|
|
|
REQUIRE(peer.publish("content-blob/" + hash, payload).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
REQUIRE(contentStore->exists(hash).has_value());
|
|
CHECK(contentStore->exists(hash).value());
|
|
|
|
const auto metadataPayload = bytes("metadata-prerequisite-content");
|
|
const auto metadataHash = digest(metadataPayload);
|
|
REQUIRE(peer.publish("content-blob/" + metadataHash, metadataPayload).has_value());
|
|
REQUIRE(peer.publish("document/" + metadataHash, bytes("not-json")).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
// A malformed document fails only the metadata stage.
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
CHECK(applyHealth().failuresIn(ApplyStage::Metadata) >= 1);
|
|
CHECK(applyHealth().lastFailureStage == "metadata");
|
|
|
|
yams::memory_sync::MetadataDocumentRecord record;
|
|
record.documentId = metadataHash;
|
|
record.contentHash = metadataHash;
|
|
record.filePath = "/peer/prerequisite.md";
|
|
record.fileName = "prerequisite.md";
|
|
record.fileExtension = ".md";
|
|
record.fileSize = static_cast<std::int64_t>(metadataPayload.size());
|
|
record.mimeType = "text/markdown";
|
|
REQUIRE(
|
|
peer.publish("document/" + metadataHash, bytes(nlohmann::json(record).dump())).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
const auto document = repository->getDocumentByHash(metadataHash);
|
|
REQUIRE(document.has_value());
|
|
CHECK(document.value().has_value());
|
|
|
|
yams::memory_sync::TopologyNodeRecord topologyRecord;
|
|
topologyRecord.nodeKey = "payload-node";
|
|
topologyRecord.type = "symbol";
|
|
topologyRecord.label = "payload";
|
|
REQUIRE(
|
|
peer.publish("topology-node/envelope-node", bytes(nlohmann::json(topologyRecord).dump()))
|
|
.has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
// A topology identity mismatch fails the topology stage after the others completed.
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
CHECK(applyHealth().failuresIn(ApplyStage::Topology) >= 1);
|
|
CHECK(applyHealth().lastFailureStage == "topology");
|
|
auto kgStoreBeforeRepair = serviceManager->getKgStore();
|
|
REQUIRE(kgStoreBeforeRepair != nullptr);
|
|
const auto rejectedNode = kgStoreBeforeRepair->getNodeByKey("payload-node");
|
|
REQUIRE(rejectedNode.has_value());
|
|
CHECK_FALSE(rejectedNode.value().has_value());
|
|
|
|
topologyRecord.nodeKey = "envelope-node";
|
|
topologyRecord.label = "repaired";
|
|
REQUIRE(
|
|
peer.publish("topology-node/envelope-node", bytes(nlohmann::json(topologyRecord).dump()))
|
|
.has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
stages.clear();
|
|
asTestStageCaller([&] { serviceManager->testingApplyMemorySyncWinners(); });
|
|
CHECK(stages == std::vector<std::string>{"apply.after_content", "apply.after_metadata",
|
|
"apply.after_vector", "apply.after_topology"});
|
|
auto kgStore = serviceManager->getKgStore();
|
|
REQUIRE(kgStore != nullptr);
|
|
const auto repairedNode = kgStore->getNodeByKey("envelope-node");
|
|
REQUIRE(repairedNode.has_value());
|
|
CHECK(repairedNode.value().has_value());
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync requires content before metadata visibility",
|
|
"[integration][daemon][memory-sync][prerequisite][content]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174013",
|
|
"content-prerequisite-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
|
|
const auto localHash = digest(bytes("missing-local-content"));
|
|
yams::metadata::BatchDocumentInsert local;
|
|
local.info.filePath = "/local/missing.md";
|
|
local.info.fileName = "missing.md";
|
|
local.info.fileExtension = ".md";
|
|
local.info.sha256Hash = localHash;
|
|
local.info.mimeType = "text/markdown";
|
|
std::vector<yams::metadata::BatchDocumentInsert> localInserts;
|
|
localInserts.push_back(std::move(local));
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(localInserts).has_value());
|
|
|
|
serviceManager->testingPublishMemorySyncBackfill();
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"content-peer", 60'000, "content-prerequisite-corpus",
|
|
1}};
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached("document/" + localHash).has_value());
|
|
|
|
const auto remoteHash = digest(bytes("missing-remote-content"));
|
|
yams::memory_sync::MetadataDocumentRecord remote;
|
|
remote.documentId = remoteHash;
|
|
remote.contentHash = remoteHash;
|
|
remote.filePath = "/peer/missing.md";
|
|
remote.fileName = "missing.md";
|
|
remote.fileExtension = ".md";
|
|
remote.mimeType = "text/markdown";
|
|
REQUIRE(
|
|
peer.publish("document/" + remoteHash, bytes(nlohmann::json(remote).dump())).has_value());
|
|
REQUIRE(daemonSync->syncOnce().has_value());
|
|
serviceManager->testingApplyMemorySyncWinners();
|
|
const auto imported = repository->getDocumentByHash(remoteHash);
|
|
REQUIRE(imported.has_value());
|
|
CHECK_FALSE(imported.value().has_value());
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync stop during production backfill bounds published documents",
|
|
"[integration][daemon][memory-sync][lifecycle]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174011",
|
|
"backfill-cancellation-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(repository != nullptr);
|
|
REQUIRE(contentStore != nullptr);
|
|
|
|
std::vector<std::string> hashes;
|
|
std::vector<yams::metadata::BatchDocumentInsert> inserts;
|
|
for (int index = 0; index < 2; ++index) {
|
|
const auto payload = bytes("backfill-cancellation-" + std::to_string(index));
|
|
auto stored = contentStore->storeBytes(payload);
|
|
REQUIRE(stored.has_value());
|
|
hashes.push_back(stored.value().contentHash);
|
|
yams::metadata::BatchDocumentInsert item;
|
|
item.info.filePath = "/local/backfill-" + std::to_string(index) + ".md";
|
|
item.info.fileName = "backfill-" + std::to_string(index) + ".md";
|
|
item.info.fileExtension = ".md";
|
|
item.info.fileSize = static_cast<std::int64_t>(payload.size());
|
|
item.info.sha256Hash = stored.value().contentHash;
|
|
item.info.mimeType = "text/markdown";
|
|
inserts.push_back(std::move(item));
|
|
}
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(inserts).has_value());
|
|
|
|
std::atomic<std::size_t> completedDocuments{0};
|
|
// Stages are observed per call; never let an unchanged-input cycle skip them.
|
|
serviceManager->testingDisableMemorySyncApplySkip();
|
|
StopFromStage stopFromStage;
|
|
serviceManager->testingSetMemorySyncStageObserver([&](std::string_view stage) {
|
|
if (stage == "backfill.after_document") {
|
|
completedDocuments.fetch_add(1, std::memory_order_relaxed);
|
|
stopFromStage.request(*daemonSync);
|
|
}
|
|
});
|
|
serviceManager->testingPublishMemorySyncBackfill();
|
|
stopFromStage.join();
|
|
CHECK(completedDocuments.load(std::memory_order_relaxed) == 1);
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"backfill-peer", 60'000, "backfill-cancellation-corpus",
|
|
1}};
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
std::size_t publishedDocuments = 0;
|
|
for (const auto& hash : hashes) {
|
|
if (peer.readCached("document/" + hash).has_value()) {
|
|
++publishedDocuments;
|
|
}
|
|
}
|
|
CHECK(publishedDocuments == 1);
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync backfill budget resumes document cursor",
|
|
"[integration][daemon][memory-sync][backfill]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174012",
|
|
"backfill-budget-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
// The test counts one published item per cycle it drives. A background apply cycle running
|
|
// its own backfill in between would publish an extra item.
|
|
serviceManager->testingDeferMemorySyncBackfillSchedule();
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
auto contentStore = serviceManager->getContentStore();
|
|
auto kgStore = serviceManager->getKgStore();
|
|
REQUIRE(repository != nullptr);
|
|
REQUIRE(contentStore != nullptr);
|
|
REQUIRE(kgStore != nullptr);
|
|
|
|
yams::metadata::KGNode topologyNode;
|
|
topologyNode.nodeKey = "entity:backfill-budget-source";
|
|
topologyNode.type = "backfill-budget";
|
|
auto sourceNodeId = kgStore->upsertNode(topologyNode);
|
|
REQUIRE(sourceNodeId.has_value());
|
|
topologyNode.nodeKey = "entity:backfill-budget-target";
|
|
auto targetNodeId = kgStore->upsertNode(topologyNode);
|
|
REQUIRE(targetNodeId.has_value());
|
|
yams::metadata::KGEdge topologyEdge;
|
|
topologyEdge.srcNodeId = sourceNodeId.value();
|
|
topologyEdge.dstNodeId = targetNodeId.value();
|
|
topologyEdge.relation = "backfill-budget-edge";
|
|
REQUIRE(kgStore->addEdge(topologyEdge).has_value());
|
|
|
|
std::vector<std::string> hashes;
|
|
std::vector<yams::metadata::BatchDocumentInsert> inserts;
|
|
for (int index = 0; index < 3; ++index) {
|
|
const auto payload = bytes("backfill-budget-" + std::to_string(index));
|
|
auto stored = contentStore->storeBytes(payload);
|
|
REQUIRE(stored.has_value());
|
|
hashes.push_back(stored.value().contentHash);
|
|
yams::metadata::BatchDocumentInsert item;
|
|
item.info.filePath = "/local/budget-" + std::to_string(index) + ".md";
|
|
item.info.fileName = "budget-" + std::to_string(index) + ".md";
|
|
item.info.fileExtension = ".md";
|
|
item.info.fileSize = static_cast<std::int64_t>(payload.size());
|
|
item.info.sha256Hash = stored.value().contentHash;
|
|
item.info.mimeType = "text/markdown";
|
|
inserts.push_back(std::move(item));
|
|
}
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(inserts).has_value());
|
|
|
|
serviceManager->testingSetMemorySyncBackfillItemBudget(1);
|
|
std::vector<std::string> stages;
|
|
serviceManager->testingSetMemorySyncStageObserver(
|
|
[&](std::string_view stage) { stages.emplace_back(stage); });
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"backfill-peer", 60'000, "backfill-budget-corpus", 1}};
|
|
|
|
const auto publishedDocuments = [&] {
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
std::size_t published = 0;
|
|
for (const auto& hash : hashes) {
|
|
published += peer.readCached("document/" + hash).has_value() ? 1U : 0U;
|
|
}
|
|
return published;
|
|
};
|
|
const auto observed = [&](std::string_view stage) {
|
|
return std::find(stages.begin(), stages.end(), stage) != stages.end();
|
|
};
|
|
|
|
// One item per cycle, and the cursor resumes where the previous cycle stopped. Documents come
|
|
// before the records derived from them, so the topology waits until every document is out.
|
|
for (std::size_t cycle = 1; cycle <= hashes.size(); ++cycle) {
|
|
stages.clear();
|
|
serviceManager->testingPublishMemorySyncBackfill();
|
|
INFO("cycle=" << cycle);
|
|
CHECK(publishedDocuments() == cycle);
|
|
CHECK_FALSE(observed("backfill.after_node"));
|
|
}
|
|
|
|
// The topology sweep then publishes every node before the edges, so the edge follows the
|
|
// second node within a bounded number of further cycles.
|
|
stages.clear();
|
|
for (int cycle = 0; cycle < 4 && !observed("backfill.after_edge"); ++cycle) {
|
|
serviceManager->testingPublishMemorySyncBackfill();
|
|
}
|
|
CHECK(observed("backfill.after_node"));
|
|
CHECK(observed("backfill.after_edge"));
|
|
|
|
serviceManager->testingSetMemorySyncStageObserver({});
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon memory sync backfill skips a poisoned blob and continues",
|
|
"[integration][daemon][memory-sync][backfill]") {
|
|
auto options = makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174014",
|
|
"backfill-skip-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
REQUIRE(repository != nullptr);
|
|
|
|
// Two documents; the first's blob is poisoned before the backfill runs.
|
|
std::vector<std::string> hashes;
|
|
std::vector<yams::metadata::BatchDocumentInsert> inserts;
|
|
for (int index = 0; index < 2; ++index) {
|
|
const auto payload = bytes("backfill-skip-" + std::to_string(index));
|
|
auto stored = serviceManager->getContentStore()->storeBytes(payload);
|
|
REQUIRE(stored.has_value());
|
|
hashes.push_back(stored.value().contentHash);
|
|
yams::metadata::BatchDocumentInsert item;
|
|
item.info.filePath = "/local/skip-" + std::to_string(index) + ".md";
|
|
item.info.fileName = "skip-" + std::to_string(index) + ".md";
|
|
item.info.fileExtension = ".md";
|
|
item.info.fileSize = static_cast<std::int64_t>(payload.size());
|
|
item.info.sha256Hash = stored.value().contentHash;
|
|
item.info.mimeType = "text/markdown";
|
|
inserts.push_back(std::move(item));
|
|
}
|
|
REQUIRE(repository->batchInsertDocumentsWithMetadata(inserts).has_value());
|
|
|
|
// Poison the first blob so its manifest is unrecoverable, then run backfill.
|
|
serviceManager->__test_setContentStore(std::make_shared<ManifestPoisonedContentStore>(
|
|
serviceManager->getContentStore(), hashes.front()));
|
|
std::vector<std::string> stages;
|
|
serviceManager->testingSetMemorySyncStageObserver(
|
|
[&](std::string_view stage) { stages.emplace_back(stage); });
|
|
serviceManager->testingPublishMemorySyncBackfill();
|
|
|
|
CHECK(std::find(stages.begin(), stages.end(), "backfill.skip_document") != stages.end());
|
|
|
|
// The poisoned document is never replicated; the backfill advances past it
|
|
// and publishes the next document instead of wedging the domain.
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "shared-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"backfill-skip-peer", 60'000, "backfill-skip-corpus",
|
|
1}};
|
|
REQUIRE(peer.syncOnce().has_value());
|
|
CHECK_FALSE(peer.readCached("document/" + hashes.front()).has_value());
|
|
CHECK(peer.readCached("document/" + hashes.back()).has_value());
|
|
|
|
serviceManager->testingSetMemorySyncStageObserver({});
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon direct deltas immediately hydrate content and metadata",
|
|
"[integration][daemon][memory-sync][direct-delta]") {
|
|
auto options =
|
|
makeMemorySyncHarnessOptions("123e4567-e89b-42d3-a456-426614174015", "direct-apply-corpus");
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
REQUIRE(harness.start(30s));
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
auto repository = serviceManager->getMetadataRepo();
|
|
auto contentStore = serviceManager->getContentStore();
|
|
REQUIRE(repository != nullptr);
|
|
REQUIRE(contentStore != nullptr);
|
|
|
|
yams::memory_sync::MemorySyncService peer{
|
|
makeFilesystemBackend(harness.dataDir() / "direct-peer-memory"),
|
|
yams::memory_sync::MemorySyncConfig{"direct-peer", 60'000, "direct-apply-corpus", 1}};
|
|
const auto payload = bytes("direct-delta-searchable-content");
|
|
const auto hash = digest(payload);
|
|
REQUIRE(peer.publish("content-blob/" + hash, payload).has_value());
|
|
|
|
yams::memory_sync::MetadataDocumentRecord remote;
|
|
remote.documentId = hash;
|
|
remote.filePath = "/peer/direct.md";
|
|
remote.fileName = "direct.md";
|
|
remote.fileExtension = ".md";
|
|
remote.fileSize = static_cast<std::int64_t>(payload.size());
|
|
remote.contentHash = hash;
|
|
remote.mimeType = "text/markdown";
|
|
remote.contentExtracted = true;
|
|
REQUIRE(peer.publish("document/" + hash, bytes(nlohmann::json(remote).dump())).has_value());
|
|
|
|
auto deltas = peer.exportLocalDeltasAfter({});
|
|
REQUIRE(deltas.has_value());
|
|
REQUIRE(deltas.value().deltas.size() == 2);
|
|
auto applied = daemonSync->applyDeltas(deltas.value().deltas);
|
|
REQUIRE(applied.has_value());
|
|
CHECK(applied.value().merged == 2);
|
|
|
|
auto contentExists = contentStore->exists(hash);
|
|
REQUIRE(contentExists.has_value());
|
|
CHECK(contentExists.value());
|
|
auto replicatedBytes = contentStore->retrieveBytes(hash);
|
|
REQUIRE(replicatedBytes.has_value());
|
|
CHECK(replicatedBytes.value() == payload);
|
|
auto imported = repository->getDocumentByHash(hash);
|
|
REQUIRE(imported.has_value());
|
|
REQUIRE(imported.value().has_value());
|
|
CHECK(imported.value()->fileName == "direct.md");
|
|
CHECK(imported.value()->sha256Hash == hash);
|
|
|
|
// Remote metadata is not enough for retrieval parity: applying a direct delta must enqueue
|
|
// the same extraction/FTS path used by a local add.
|
|
std::optional<yams::metadata::DocumentContent> indexedContent;
|
|
const auto searchDeadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while (std::chrono::steady_clock::now() < searchDeadline) {
|
|
auto content = repository->getContent(imported.value()->id);
|
|
REQUIRE(content.has_value());
|
|
if (content.value()) {
|
|
indexedContent = std::move(*content.value());
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
REQUIRE(indexedContent.has_value());
|
|
CHECK(indexedContent->contentText.find("direct-delta-searchable-content") != std::string::npos);
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
}
|
|
|
|
TEST_CASE("Daemon temporary memory sync converges inside one session and cleans only its namespace",
|
|
"[integration][daemon][memory-sync][temporary]") {
|
|
yams::test::DaemonHarness::Options options;
|
|
options.enableModelProvider = false;
|
|
options.useMockModelProvider = false;
|
|
options.autoLoadPlugins = false;
|
|
options.enableAutoRepair = false;
|
|
options.isolateConfig = true;
|
|
options.isolateState = true;
|
|
options.configureDaemon = [](yams::daemon::DaemonConfig& config) {
|
|
config.memorySync.enabled = true;
|
|
config.memorySync.corpusScope = yams::memory_sync::CorpusScope::Shared;
|
|
config.memorySync.nodeId = "123e4567-e89b-42d3-a456-426614174001";
|
|
config.memorySync.corpusId = "temporary-integration-corpus";
|
|
config.memorySync.corpusEpoch = 1;
|
|
config.memorySync.transport = "shared-store";
|
|
config.memorySync.backend = "filesystem";
|
|
config.memorySync.path = "shared-memory";
|
|
config.memorySync.syncIntervalMs = 25;
|
|
config.memorySync.mode = "temporary";
|
|
config.memorySync.sessionId = "integration-session";
|
|
};
|
|
// DaemonHarness constructs YamsDaemon directly, so this case injects memory-sync policy
|
|
// through configureDaemon. ConfigResolver's normal TOML path is covered separately.
|
|
options.isolatedConfigContents = R"(
|
|
[daemon]
|
|
auto_load_plugins = false
|
|
auto_repair = false
|
|
)";
|
|
|
|
yams::test::DaemonHarness harness{std::move(options)};
|
|
const auto sharedRoot = harness.dataDir() / "shared-memory";
|
|
const auto sessionPath = sharedRoot / ".sessions" / "integration-session";
|
|
const auto otherSessionPath = sharedRoot / ".sessions" / "other-session";
|
|
std::filesystem::create_directories(sharedRoot);
|
|
const auto persistentMarker = sharedRoot / "persistent-marker";
|
|
{
|
|
std::ofstream marker(persistentMarker);
|
|
REQUIRE(marker.good());
|
|
marker << "keep";
|
|
}
|
|
|
|
REQUIRE(harness.start(30s));
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto* daemonSync = serviceManager->testingMemorySyncService();
|
|
REQUIRE(daemonSync != nullptr);
|
|
|
|
yams::memory_sync::MemorySyncService sameSession{
|
|
makeFilesystemBackend(sessionPath),
|
|
yams::memory_sync::MemorySyncConfig{"peer-session", 25, "temporary-integration-corpus", 1}};
|
|
REQUIRE(sameSession.publish("temporary-key", bytes("same-session")).has_value());
|
|
const auto convergenceDeadline = std::chrono::steady_clock::now() + scaledTimeout(3s);
|
|
while (!daemonSync->testingHasMergedRecord("temporary-key") &&
|
|
std::chrono::steady_clock::now() < convergenceDeadline) {
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
REQUIRE(daemonSync->testingHasMergedRecord("temporary-key"));
|
|
|
|
yams::memory_sync::MemorySyncService otherSession{
|
|
makeFilesystemBackend(otherSessionPath),
|
|
yams::memory_sync::MemorySyncConfig{"other-session", 25, "temporary-integration-corpus",
|
|
1}};
|
|
REQUIRE(otherSession.publish("isolated-key", bytes("other-session")).has_value());
|
|
std::this_thread::sleep_for(100ms);
|
|
CHECK_FALSE(daemonSync->testingHasMergedRecord("isolated-key"));
|
|
|
|
harness.stop();
|
|
CHECK(harness.shutdownSucceeded());
|
|
CHECK(std::filesystem::exists(persistentMarker));
|
|
CHECK(std::filesystem::exists(otherSessionPath));
|
|
CHECK((!std::filesystem::exists(sessionPath) || std::filesystem::is_empty(sessionPath)));
|
|
}
|