556 lines
19 KiB
C++
556 lines
19 KiB
C++
// ResourceGovernor Integration Tests
|
|
// Tests that resource governor throttling behavior actually kicks in during real
|
|
// daemon operations, verifying the integration between:
|
|
// TuneAdvisor (thresholds) → ResourceGovernor (pressure detection) → Daemon (status)
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include "test_async_helpers.h"
|
|
#include "test_daemon_harness.h"
|
|
#include "../../common/env_compat.h"
|
|
#include "../../common/test_helpers_catch2.h"
|
|
|
|
#include <yams/app/services/document_ingestion_service.h>
|
|
#include <yams/daemon/client/daemon_client.h>
|
|
#include <yams/daemon/components/ResourceGovernor.h>
|
|
#include <yams/daemon/components/TuneAdvisor.h>
|
|
#include <yams/compat/unistd.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
using namespace yams::daemon;
|
|
using namespace yams::test;
|
|
using namespace std::chrono_literals;
|
|
|
|
namespace {
|
|
|
|
/// RAII guard for TuneAdvisor CPU threshold restoration
|
|
class CpuThresholdGuard {
|
|
double prev_;
|
|
|
|
public:
|
|
explicit CpuThresholdGuard(double newThreshold) {
|
|
prev_ = TuneAdvisor::cpuHighThresholdPercent();
|
|
TuneAdvisor::setCpuHighThresholdPercent(newThreshold);
|
|
}
|
|
|
|
~CpuThresholdGuard() { TuneAdvisor::setCpuHighThresholdPercent(prev_); }
|
|
|
|
CpuThresholdGuard(const CpuThresholdGuard&) = delete;
|
|
CpuThresholdGuard& operator=(const CpuThresholdGuard&) = delete;
|
|
};
|
|
|
|
using yams::test::ScopedEnvVar;
|
|
|
|
/// Wait for daemon to complete initial setup and governor to be active.
|
|
/// The governor budget is populated by TuningManager tick, which runs on ~500ms intervals.
|
|
/// We check the ResourceGovernor singleton directly for reliability.
|
|
bool waitForGovernorActive(std::chrono::milliseconds timeout) {
|
|
auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto& governor = ResourceGovernor::instance();
|
|
auto snapshot = governor.getSnapshot();
|
|
if (snapshot.memoryBudgetBytes > 0) {
|
|
return true;
|
|
}
|
|
// TuningManager ticks every ~500ms, so don't poll too aggressively
|
|
std::this_thread::sleep_for(300ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Wait for embedding queue to drain (processing complete)
|
|
bool waitForEmbeddingDrain(DaemonClient& client, std::chrono::milliseconds timeout) {
|
|
auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
int stableCount = 0;
|
|
constexpr int stableRequired = 5;
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto status = yams::cli::run_sync(client.status(), 5s);
|
|
if (!status) {
|
|
std::this_thread::sleep_for(500ms);
|
|
continue;
|
|
}
|
|
|
|
const auto& st = status.value();
|
|
uint64_t embedQueued = 0;
|
|
uint64_t embedInFlight = 0;
|
|
uint64_t postQueued = 0;
|
|
uint64_t postInFlight = 0;
|
|
|
|
if (auto it = st.requestCounts.find("embed_svc_queued"); it != st.requestCounts.end()) {
|
|
embedQueued = it->second;
|
|
}
|
|
if (auto it = st.requestCounts.find("embed_in_flight"); it != st.requestCounts.end()) {
|
|
embedInFlight = it->second;
|
|
}
|
|
if (auto it = st.requestCounts.find("post_ingest_queued"); it != st.requestCounts.end()) {
|
|
postQueued = it->second;
|
|
}
|
|
if (auto it = st.requestCounts.find("post_ingest_inflight"); it != st.requestCounts.end()) {
|
|
postInFlight = it->second;
|
|
}
|
|
|
|
const bool embedDrained = (embedQueued == 0 && embedInFlight == 0);
|
|
const bool postDrained = (postQueued == 0 && postInFlight == 0);
|
|
|
|
if (embedDrained && postDrained) {
|
|
++stableCount;
|
|
if (stableCount >= stableRequired) {
|
|
return true;
|
|
}
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
|
|
std::this_thread::sleep_for(500ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Add a test document via daemon
|
|
bool addTestDocument(const DaemonHarness& harness, int docId) {
|
|
const std::string docName = "governor_test_doc_" + std::to_string(docId) + ".txt";
|
|
auto path = harness.dataDir() / docName;
|
|
|
|
std::ofstream ofs(path);
|
|
if (!ofs) {
|
|
return false;
|
|
}
|
|
|
|
// Create content that will generate some processing work
|
|
ofs << "Test document " << docId << " for resource governor integration testing.\n";
|
|
ofs << "This document contains content to exercise the ingest pipeline.\n";
|
|
for (int i = 0; i < 10; ++i) {
|
|
ofs << "Line " << i << ": Sample content for processing.\n";
|
|
}
|
|
ofs.close();
|
|
|
|
yams::app::services::DocumentIngestionService docSvc;
|
|
yams::app::services::AddOptions opts;
|
|
opts.socketPath = harness.socketPath().string();
|
|
opts.explicitDataDir = harness.dataDir().string();
|
|
opts.path = path.string();
|
|
opts.noEmbeddings = true; // Skip embeddings for faster tests
|
|
|
|
auto result = docSvc.addViaDaemon(opts);
|
|
return result.has_value() && !result.value().hash.empty();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// =============================================================================
|
|
// Test 1: Verify Governor Metrics Available via Status
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ResourceGovernor metrics exposed in daemon status", "[integration][governor][status]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
ClientConfig clientCfg;
|
|
clientCfg.socketPath = harness.socketPath();
|
|
clientCfg.requestTimeout = 10s;
|
|
clientCfg.autoStart = false;
|
|
DaemonClient client(clientCfg);
|
|
|
|
auto connected = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connected.has_value());
|
|
|
|
// Wait for governor to be active (via singleton check)
|
|
REQUIRE(waitForGovernorActive(30s));
|
|
|
|
// Wait a bit longer for FSM metrics to propagate to status response
|
|
// The TuningManager updates FSM metrics on its tick cycle (~500ms)
|
|
std::this_thread::sleep_for(1s);
|
|
|
|
auto status = yams::cli::run_sync(client.status(), 5s);
|
|
REQUIRE(status.has_value());
|
|
|
|
// Check governor budget via singleton (FSM propagation may be slow)
|
|
auto& governor = ResourceGovernor::instance();
|
|
auto snapshot = governor.getSnapshot();
|
|
|
|
INFO("governorBudgetBytes (status)=" << status.value().governorBudgetBytes);
|
|
INFO("governorBudgetBytes (singleton)=" << snapshot.memoryBudgetBytes);
|
|
CHECK(snapshot.memoryBudgetBytes > 0);
|
|
|
|
INFO("governorPressureLevel=" << static_cast<int>(status.value().governorPressureLevel));
|
|
CHECK(status.value().governorPressureLevel <= 3);
|
|
|
|
INFO("governorRssBytes=" << snapshot.rssBytes);
|
|
CHECK(snapshot.rssBytes > 0);
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 2: Verify Governor Singleton Accessibility During Daemon Operation
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ResourceGovernor singleton is accessible during daemon operation",
|
|
"[integration][governor][singleton]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
auto& governor = ResourceGovernor::instance();
|
|
|
|
// Test: Governor provides pressure level
|
|
auto level = governor.getPressureLevel();
|
|
INFO("pressureLevel=" << static_cast<int>(level));
|
|
CHECK((level == ResourcePressureLevel::Normal || level == ResourcePressureLevel::Warning ||
|
|
level == ResourcePressureLevel::Critical || level == ResourcePressureLevel::Emergency));
|
|
|
|
// Test: Governor provides scaling caps
|
|
auto caps = governor.getScalingCaps();
|
|
INFO("allowModelLoads=" << caps.allowModelLoads);
|
|
INFO("allowNewIngest=" << caps.allowNewIngest);
|
|
// At normal pressure, model loads and ingest should be allowed
|
|
if (level == ResourcePressureLevel::Normal) {
|
|
CHECK(caps.allowModelLoads);
|
|
CHECK(caps.allowNewIngest);
|
|
}
|
|
|
|
// Test: Governor max concurrency values are positive
|
|
CHECK(governor.maxIngestWorkers() > 0);
|
|
CHECK(governor.maxSearchConcurrency() > 0);
|
|
|
|
// Test: Governor admission control is callable
|
|
bool canWork = governor.canAdmitWork();
|
|
bool canLoad = governor.canLoadModel(1024ULL * 1024ULL); // 1 MB
|
|
bool canScale = governor.canScaleUp("test", 1);
|
|
|
|
INFO("canAdmitWork=" << canWork);
|
|
INFO("canLoadModel(1MB)=" << canLoad);
|
|
INFO("canScaleUp(test,1)=" << canScale);
|
|
|
|
// At normal pressure, these should all return true
|
|
if (level == ResourcePressureLevel::Normal) {
|
|
CHECK(canWork);
|
|
CHECK(canLoad);
|
|
CHECK(canScale);
|
|
}
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
TEST_CASE("ResourceGovernor phase-4 policy surfaces respond during daemon operation",
|
|
"[integration][governor][policy]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
REQUIRE(waitForGovernorActive(30s));
|
|
|
|
auto& governor = ResourceGovernor::instance();
|
|
|
|
// Normalize singleton policy state before making policy assertions.
|
|
governor.reportDbLockContention(0, 1);
|
|
(void)governor.recommendConnectionSlotTarget(16u, 8u, 4u, 64u, 8u, 1);
|
|
|
|
const auto retryBaseline =
|
|
governor.recommendRetryAfterMs(0, 200, 0, 1024 * 1024, 1, 128, 0, 128, 500);
|
|
const auto retryOverloaded = governor.recommendRetryAfterMs(
|
|
300, 200, 4 * 1024 * 1024, 1024 * 1024, 200, 128, 128, 128, 500);
|
|
CHECK(retryOverloaded >= retryBaseline);
|
|
CHECK(retryOverloaded <= 5000);
|
|
|
|
const auto pauseIdle = governor.recommendBackpressureReadPauseMs(100);
|
|
CHECK(pauseIdle >= 1);
|
|
CHECK(pauseIdle <= 5000);
|
|
|
|
const auto currentSlots = 16u;
|
|
const auto shrunk = governor.recommendConnectionSlotTarget(currentSlots, 1, 4, 64, 8, 1);
|
|
CHECK(shrunk <= currentSlots);
|
|
CHECK(shrunk >= 4u);
|
|
|
|
governor.reportDbLockContention(100, 10);
|
|
CHECK(governor.capKgConcurrencyForDbContention(8) <= 2);
|
|
CHECK(governor.capEmbedConcurrencyForDbContention(8) <= 1);
|
|
|
|
governor.reportDbLockContention(0, 10);
|
|
CHECK(governor.capKgConcurrencyForDbContention(8) == 8);
|
|
CHECK(governor.capEmbedConcurrencyForDbContention(4) == 4);
|
|
|
|
// Leave singleton state neutral to reduce inter-test coupling.
|
|
governor.reportDbLockContention(0, 1);
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 3: Verify Pressure Level Detection with Low Threshold
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Governor detects pressure with low CPU threshold", "[integration][governor][pressure]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
// Set a very low CPU threshold so that normal daemon operation triggers Warning
|
|
CpuThresholdGuard thresholdGuard(5.0); // 5% - very low threshold
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
ClientConfig clientCfg;
|
|
clientCfg.socketPath = harness.socketPath();
|
|
clientCfg.requestTimeout = 10s;
|
|
clientCfg.autoStart = false;
|
|
DaemonClient client(clientCfg);
|
|
|
|
auto connected = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connected.has_value());
|
|
|
|
// Add some documents to create CPU load
|
|
INFO("Adding test documents to create load...");
|
|
for (int i = 0; i < 10; ++i) {
|
|
REQUIRE(addTestDocument(harness, i));
|
|
}
|
|
|
|
// Poll for pressure level change or timeout
|
|
auto deadline = std::chrono::steady_clock::now() + 30s;
|
|
bool sawPressure = false;
|
|
uint8_t maxPressureLevel = 0;
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto status = yams::cli::run_sync(client.status(), 5s);
|
|
if (status.has_value() && status.value().governorPressureLevel > 0) {
|
|
sawPressure = true;
|
|
maxPressureLevel = std::max(maxPressureLevel, status.value().governorPressureLevel);
|
|
INFO("Detected pressure level: "
|
|
<< static_cast<int>(status.value().governorPressureLevel));
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(200ms);
|
|
}
|
|
|
|
// Note: Pressure detection is non-deterministic - depends on actual CPU usage
|
|
// On fast machines, we may not see pressure. This test documents whether
|
|
// throttling CAN work under load, not that it ALWAYS triggers.
|
|
INFO("sawPressure=" << sawPressure
|
|
<< ", maxPressureLevel=" << static_cast<int>(maxPressureLevel));
|
|
|
|
// Wait for processing to complete
|
|
waitForEmbeddingDrain(client, 30s);
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 4: Verify Governor Scaling Caps Adjust Under Pressure
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Governor scaling caps adjust based on pressure level",
|
|
"[integration][governor][scaling]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
auto& governor = ResourceGovernor::instance();
|
|
|
|
// Record caps at Normal level
|
|
auto normalLevel = governor.getPressureLevel();
|
|
auto normalMaxIngest = governor.maxIngestWorkers();
|
|
auto normalMaxSearch = governor.maxSearchConcurrency();
|
|
auto normalMaxEmbed = governor.maxEmbedConcurrency();
|
|
|
|
INFO("At level=" << static_cast<int>(normalLevel));
|
|
INFO("normalMaxIngest=" << normalMaxIngest);
|
|
INFO("normalMaxSearch=" << normalMaxSearch);
|
|
INFO("normalMaxEmbed=" << normalMaxEmbed);
|
|
|
|
// These caps should be reasonable positive values
|
|
CHECK(normalMaxIngest > 0);
|
|
CHECK(normalMaxSearch > 0);
|
|
// Note: maxEmbed can be 0 at Emergency level
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 5: Verify Status Response Contains Governor Metrics After Ingest
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Status response contains governor metrics during ingest",
|
|
"[integration][governor][ingest]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
ClientConfig clientCfg;
|
|
clientCfg.socketPath = harness.socketPath();
|
|
clientCfg.requestTimeout = 10s;
|
|
clientCfg.autoStart = false;
|
|
DaemonClient client(clientCfg);
|
|
|
|
auto connected = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connected.has_value());
|
|
|
|
REQUIRE(waitForGovernorActive(30s));
|
|
|
|
// Add documents while checking status
|
|
std::vector<uint8_t> pressureLevels;
|
|
std::vector<uint64_t> budgetValues;
|
|
|
|
for (int i = 0; i < 5; ++i) {
|
|
REQUIRE(addTestDocument(harness, i));
|
|
|
|
// Check status after each add
|
|
auto status = yams::cli::run_sync(client.status(), 5s);
|
|
if (status) {
|
|
pressureLevels.push_back(status.value().governorPressureLevel);
|
|
budgetValues.push_back(status.value().governorBudgetBytes);
|
|
}
|
|
}
|
|
|
|
// Verify we got consistent governor metrics throughout
|
|
INFO("Captured " << pressureLevels.size() << " status readings");
|
|
CHECK(!pressureLevels.empty());
|
|
CHECK(!budgetValues.empty());
|
|
|
|
// Budget should remain consistent (it's a configured value)
|
|
if (budgetValues.size() >= 2) {
|
|
for (size_t i = 1; i < budgetValues.size(); ++i) {
|
|
CHECK(budgetValues[i] == budgetValues[0]);
|
|
}
|
|
}
|
|
|
|
// Wait for processing to complete
|
|
waitForEmbeddingDrain(client, 30s);
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 6: Verify Governor Snapshot Contains Valid Metrics
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ResourceGovernor snapshot contains valid metrics", "[integration][governor][snapshot]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
// Wait for governor to be ticked at least once
|
|
REQUIRE(waitForGovernorActive(30s));
|
|
|
|
auto& governor = ResourceGovernor::instance();
|
|
auto snapshot = governor.getSnapshot();
|
|
|
|
// Test: Memory metrics are populated
|
|
INFO("rssBytes=" << snapshot.rssBytes);
|
|
INFO("memoryBudgetBytes=" << snapshot.memoryBudgetBytes);
|
|
INFO("memoryPressure=" << snapshot.memoryPressure);
|
|
CHECK(snapshot.rssBytes > 0);
|
|
CHECK(snapshot.memoryBudgetBytes > 0);
|
|
|
|
// Test: Timestamp is recent
|
|
auto now = std::chrono::steady_clock::now();
|
|
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - snapshot.timestamp);
|
|
INFO("snapshotAge=" << age.count() << "s");
|
|
CHECK(age.count() < 10);
|
|
|
|
// Test: Pressure level matches snapshot
|
|
auto level = governor.getPressureLevel();
|
|
CHECK(snapshot.level == level);
|
|
|
|
harness.stop();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test 7: Verify Concurrent Status Queries Return Consistent Governor Data
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Concurrent status queries return consistent governor data",
|
|
"[integration][governor][concurrent]") {
|
|
SKIP_DAEMON_TEST_ON_WINDOWS();
|
|
|
|
DaemonHarness::Options opts;
|
|
opts.useMockModelProvider = true;
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(10s));
|
|
|
|
ClientConfig clientCfg;
|
|
clientCfg.socketPath = harness.socketPath();
|
|
clientCfg.requestTimeout = 10s;
|
|
clientCfg.autoStart = false;
|
|
DaemonClient client(clientCfg);
|
|
|
|
auto connected = yams::cli::run_sync(client.connect(), 5s);
|
|
REQUIRE(connected.has_value());
|
|
|
|
REQUIRE(waitForGovernorActive(30s));
|
|
|
|
// Issue multiple concurrent status queries
|
|
// This test verifies thread safety of concurrent DaemonClient::status() calls.
|
|
// Prior to the fix in asio_connection.cpp, this would crash with SIGSEGV.
|
|
std::atomic<int> successCount{0};
|
|
std::atomic<int> errorCount{0};
|
|
std::vector<uint64_t> budgetValues;
|
|
std::mutex valuesMutex;
|
|
|
|
auto worker = [&]() {
|
|
auto status = yams::cli::run_sync(client.status(), 5s);
|
|
if (status.has_value()) {
|
|
++successCount;
|
|
// governorBudgetBytes may be 0 if TuningManager hasn't updated
|
|
// FsmMetricsRegistry yet - that's OK, we're testing thread safety
|
|
auto budget = status.value().governorBudgetBytes;
|
|
if (budget > 0) {
|
|
std::lock_guard<std::mutex> lock(valuesMutex);
|
|
budgetValues.push_back(budget);
|
|
}
|
|
} else {
|
|
++errorCount;
|
|
}
|
|
};
|
|
|
|
std::vector<std::thread> threads;
|
|
constexpr int numQueries = 8;
|
|
threads.reserve(numQueries);
|
|
|
|
for (int i = 0; i < numQueries; ++i) {
|
|
threads.emplace_back(worker);
|
|
}
|
|
|
|
for (auto& t : threads) {
|
|
t.join();
|
|
}
|
|
|
|
INFO("successCount=" << successCount << ", errorCount=" << errorCount);
|
|
// All concurrent status queries should succeed (no crashes, no errors)
|
|
CHECK(successCount == numQueries);
|
|
CHECK(errorCount == 0);
|
|
|
|
// If any budget values were captured, they should all be consistent
|
|
if (budgetValues.size() >= 2) {
|
|
for (size_t i = 1; i < budgetValues.size(); ++i) {
|
|
CHECK(budgetValues[i] == budgetValues[0]);
|
|
}
|
|
}
|
|
|
|
harness.stop();
|
|
}
|