yams/tests/unit/search/component_fanout_catch2_test.cpp
Trevon d398556ee5
fix(tests): replace frozen expectations and harden release gates (#323)
* test(search,daemon,storage): assert behaviour instead of frozen literals

Several suites encoded exact internal values that legitimate tuning and
ranking changes are allowed to move: SCIENTIFIC-era profile weights and
rrfK values, corpus-statistic figures, and hardcoded timeout/cancellation
numbers. They failed on every platform after those changes landed, so the
real behaviour regressions they should catch were hidden behind noise.

Replace the frozen literals with contract assertions: weights are finite,
non-negative and normalize, state selection still holds, capability-gated
sources stay zeroed, and hybrid results keep matching canonical hash/path
sets. Deadlines are not widened and the daemon paths they cover are
unchanged.

Also add independent corpus and adapter oracles, deterministic collector
cancellation and late-result gates, and synchronize ServiceManager
shutdown observation, so the tests fail for the behaviour they name.

The portability allowlist keeps its two reviewed raw test-env reads; only
their line numbers moved.

Signed-off-by: trvon <git@trevon.dev>

* ci(tests): summarise Meson failures without flooding the log

A failing test run printed the whole Meson suite output, so the actual
failing test names and their assertion messages were buried and the
uploaded logs were dominated by passing-test chatter.

Add a bounded reporter that extracts the failing test names plus their
surrounding diagnostics, keeps an overall cap on emitted lines, and is
unit-tested against representative Meson output. Wire it into the Tests
workflow so failures stay readable.

Signed-off-by: trvon <git@trevon.dev>

* build(deb): validate candidate identity, mapping and upgrade ordering

The Debian packaging scripts took filenames and versions on trust, so a
misnamed candidate or a wrong package ordering could pass validation while
the real install path was never exercised.

Fail closed on candidate identity, version and architecture, verify the
package-to-source mapping, seed the legacy-only layout, wait on the actual
services, and assert install/upgrade/remove ordering for both Debian and
RPM in an offline container. Cases that cannot be proven on a host are
reported as skipped rather than silently passing.

Signed-off-by: trvon <git@trevon.dev>

---------

Signed-off-by: trvon <git@trevon.dev>
2026-10-19 19:16:57 -06:00

410 lines
16 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include "src/search/search_component_fanout_internal.h"
#include <boost/asio/executor_work_guard.hpp>
#include <boost/asio/io_context.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <future>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
using yams::Error;
using yams::ErrorCode;
using yams::Result;
using yams::search::ComponentResult;
using yams::search::SearchEngineConfig;
using yams::search::SearchTraceCollector;
using yams::search::detail::ComponentFanoutCollector;
using yams::search::detail::ComponentFanoutSinks;
using yams::search::detail::ComponentStatus;
using yams::search::detail::scheduleComponent;
namespace {
struct FanoutFixture {
SearchEngineConfig config;
SearchTraceCollector trace{config};
std::vector<ComponentResult> allComponentResults;
std::map<std::string, int64_t> componentTiming;
std::vector<std::string> contributing;
std::vector<std::string> failed;
std::vector<std::string> timedOut;
std::atomic<uint64_t> timedOutQueries{0};
std::atomic<uint64_t> queryCount{0};
std::atomic<uint64_t> avgTime{0};
std::shared_ptr<std::atomic<bool>> cancellationSignal;
std::shared_ptr<std::atomic<bool>> hostCancellationSignal;
ComponentFanoutCollector makeCollector() {
return ComponentFanoutCollector(config, trace,
ComponentFanoutSinks{allComponentResults, componentTiming,
contributing, failed, timedOut,
timedOutQueries},
cancellationSignal, hostCancellationSignal);
}
};
ComponentResult makeComponent(std::string hash, float score) {
ComponentResult component;
component.documentHash = std::move(hash);
component.filePath = component.documentHash;
component.score = score;
component.source = ComponentResult::Source::Text;
return component;
}
constexpr auto kHangWatchdog = std::chrono::seconds(10);
class GatedWorkState {
public:
void enterAndWaitForRelease() {
std::unique_lock lock(mutex_);
entered_ = true;
condition_.notify_all();
condition_.wait(lock, [this]() { return released_; });
}
bool waitForEntry(std::chrono::steady_clock::duration timeout) {
std::unique_lock lock(mutex_);
return condition_.wait_for(lock, timeout, [this]() { return entered_; });
}
void release() noexcept {
{
std::lock_guard lock(mutex_);
released_ = true;
}
condition_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable condition_;
bool entered_{false};
bool released_{false};
};
class GatedExecutor {
public:
GatedExecutor()
: state_(std::make_shared<GatedWorkState>()), workGuard_(boost::asio::make_work_guard(io_)),
runner_([this]() { io_.run(); }) {}
~GatedExecutor() {
state_->release();
workGuard_.reset();
io_.stop();
if (runner_.joinable()) {
runner_.join();
}
}
GatedExecutor(const GatedExecutor&) = delete;
GatedExecutor& operator=(const GatedExecutor&) = delete;
std::shared_ptr<GatedWorkState> state() const { return state_; }
std::optional<boost::asio::any_io_executor> executor() { return io_.get_executor(); }
private:
std::shared_ptr<GatedWorkState> state_;
boost::asio::io_context io_;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> workGuard_;
std::thread runner_; // Started last: all teardown state is already fully constructed.
};
class ScopedGateRelease {
public:
explicit ScopedGateRelease(std::shared_ptr<GatedWorkState> state) : state_(std::move(state)) {}
~ScopedGateRelease() { release(); }
ScopedGateRelease(const ScopedGateRelease&) = delete;
ScopedGateRelease& operator=(const ScopedGateRelease&) = delete;
void release() noexcept {
if (state_) {
state_->release();
state_.reset();
}
}
private:
std::shared_ptr<GatedWorkState> state_;
};
ComponentFanoutCollector::Future scheduleGatedComponent(GatedExecutor& execution,
std::string hash) {
auto state = execution.state();
return scheduleComponent(1.0f, execution.executor(),
[state = std::move(state),
hash = std::move(hash)]() -> Result<std::vector<ComponentResult>> {
state->enterAndWaitForRelease();
return std::vector<ComponentResult>{makeComponent(hash, 0.75f)};
});
}
void releaseAndConsumeLateResult(ComponentFanoutCollector::Future& future,
ScopedGateRelease& release, const std::string& expectedHash) {
release.release();
REQUIRE(future.wait_for(kHangWatchdog) == std::future_status::ready);
auto result = future.get();
REQUIRE(result);
REQUIRE(result.value().size() == 1);
CHECK(result.value().front().documentHash == expectedHash);
}
} // namespace
TEST_CASE("fanout collector appends results and marks contributing on success",
"[search][fanout][catch2]") {
FanoutFixture fixture;
auto collector = fixture.makeCollector();
auto future =
scheduleComponent(1.0f, std::nullopt, []() -> Result<std::vector<ComponentResult>> {
return std::vector<ComponentResult>{makeComponent("doc-a", 0.9f)};
});
collector.collectAndRecord(future, "text", fixture.queryCount, fixture.avgTime);
REQUIRE(fixture.allComponentResults.size() == 1);
CHECK(fixture.allComponentResults.front().documentHash == "doc-a");
REQUIRE(fixture.contributing == std::vector<std::string>{"text"});
CHECK(fixture.failed.empty());
CHECK(fixture.timedOut.empty());
CHECK(fixture.componentTiming.contains("text"));
CHECK(fixture.queryCount.load() == 1);
}
TEST_CASE("fanout collector treats empty success as non-contributing", "[search][fanout][catch2]") {
FanoutFixture fixture;
auto collector = fixture.makeCollector();
auto future =
scheduleComponent(1.0f, std::nullopt, []() -> Result<std::vector<ComponentResult>> {
return std::vector<ComponentResult>{};
});
collector.collectAndRecord(future, "tag", fixture.queryCount, fixture.avgTime);
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
CHECK(fixture.queryCount.load() == 1);
}
TEST_CASE("fanout collector records failure without appending results",
"[search][fanout][catch2]") {
FanoutFixture fixture;
auto collector = fixture.makeCollector();
auto future =
scheduleComponent(1.0f, std::nullopt, []() -> Result<std::vector<ComponentResult>> {
return Error{ErrorCode::InternalError, "boom"};
});
collector.collectAndRecord(future, "kg", fixture.queryCount, fixture.avgTime);
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.contributing.empty());
REQUIRE(fixture.failed == std::vector<std::string>{"kg"});
CHECK(fixture.timedOut.empty());
CHECK(fixture.queryCount.load() == 0);
}
TEST_CASE("fanout collector records timeout and increments timed-out counter",
"[search][fanout][catch2]") {
FanoutFixture fixture;
fixture.config.componentTimeout = std::chrono::milliseconds(1);
auto collector = fixture.makeCollector();
GatedExecutor execution;
auto future = scheduleGatedComponent(execution, "doc-after-timeout");
REQUIRE(execution.state()->waitForEntry(kHangWatchdog));
auto collection = std::async(std::launch::async, [&]() {
return collector.collectAndRecord(future, "vector", fixture.queryCount, fixture.avgTime);
});
// Declared after the async future so assertion unwinding releases the provider before a
// future destructor can wait for collection to finish.
ScopedGateRelease releaseBeforeFutureDestruction(execution.state());
REQUIRE(collection.wait_for(kHangWatchdog) == std::future_status::ready);
const auto status = collection.get();
CHECK(status == ComponentStatus::TimedOut);
REQUIRE(future.valid());
CHECK(future.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout);
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.contributing.empty());
REQUIRE(fixture.timedOut == std::vector<std::string>{"vector"});
CHECK(fixture.failed.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.timedOutQueries.load() == 1);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
releaseAndConsumeLateResult(future, releaseBeforeFutureDestruction, "doc-after-timeout");
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
REQUIRE(fixture.timedOut == std::vector<std::string>{"vector"});
CHECK(fixture.timedOutQueries.load() == 1);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
}
TEST_CASE("fanout collector stops waiting when its request is canceled",
"[search][fanout][cancel][catch2]") {
FanoutFixture fixture;
fixture.config.componentTimeout = std::chrono::seconds(30);
fixture.cancellationSignal = std::make_shared<std::atomic<bool>>(false);
auto collector = fixture.makeCollector();
GatedExecutor execution;
auto future = scheduleGatedComponent(execution, "doc-after-request-cancel");
REQUIRE(execution.state()->waitForEntry(kHangWatchdog));
std::promise<void> collectionLaunched;
auto collectionLaunchedFuture = collectionLaunched.get_future();
auto collection = std::async(std::launch::async, [&]() {
collectionLaunched.set_value();
return collector.collect(future, "vector", fixture.queryCount, fixture.avgTime);
});
ScopedGateRelease releaseBeforeFutureDestruction(execution.state());
REQUIRE(collectionLaunchedFuture.wait_for(kHangWatchdog) == std::future_status::ready);
// This handshake proves only that the async wrapper started, not that collect entered its wait
// loop; there is no production seam for observing that point. The pending provider below is the
// oracle that cancellation won over provider completion.
CHECK(collection.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout);
fixture.cancellationSignal->store(true, std::memory_order_release);
REQUIRE(collection.wait_for(kHangWatchdog) == std::future_status::ready);
const auto status = collection.get();
CHECK(status == ComponentStatus::Canceled);
REQUIRE(future.valid());
CHECK(future.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout);
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
CHECK(fixture.timedOut.empty());
CHECK(fixture.timedOutQueries.load() == 0);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
releaseAndConsumeLateResult(future, releaseBeforeFutureDestruction, "doc-after-request-cancel");
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
CHECK(fixture.timedOut.empty());
CHECK(fixture.timedOutQueries.load() == 0);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
}
TEST_CASE("fanout collector stops an unbounded wait when the host cancels",
"[search][fanout][cancel][catch2]") {
FanoutFixture fixture;
// componentTimeout == 0 gives the collector no deadline. This collector-only test verifies
// cancellation while provider work remains pending; it does not exercise public SearchEngine
// worker draining or prove when that higher-level request releases its worker.
fixture.config.componentTimeout = std::chrono::milliseconds(0);
fixture.hostCancellationSignal = std::make_shared<std::atomic<bool>>(false);
auto collector = fixture.makeCollector();
GatedExecutor execution;
auto future = scheduleGatedComponent(execution, "doc-after-host-cancel");
REQUIRE(execution.state()->waitForEntry(kHangWatchdog));
std::promise<void> collectionLaunched;
auto collectionLaunchedFuture = collectionLaunched.get_future();
auto collection = std::async(std::launch::async, [&]() {
collectionLaunched.set_value();
return collector.collect(future, "text", fixture.queryCount, fixture.avgTime);
});
ScopedGateRelease releaseBeforeFutureDestruction(execution.state());
REQUIRE(collectionLaunchedFuture.wait_for(kHangWatchdog) == std::future_status::ready);
// Wrapper launch is not proof that collect has entered its wait loop; no such observation seam
// exists here. The request signal is nevertheless initially false and is set asynchronously.
CHECK(collection.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout);
fixture.hostCancellationSignal->store(true, std::memory_order_release);
REQUIRE(collection.wait_for(kHangWatchdog) == std::future_status::ready);
const auto status = collection.get();
CHECK(status == ComponentStatus::Canceled);
REQUIRE(future.valid());
CHECK(future.wait_for(std::chrono::milliseconds(0)) == std::future_status::timeout);
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
CHECK(fixture.timedOut.empty());
CHECK(fixture.timedOutQueries.load() == 0);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
releaseAndConsumeLateResult(future, releaseBeforeFutureDestruction, "doc-after-host-cancel");
CHECK(fixture.allComponentResults.empty());
CHECK(fixture.componentTiming.empty());
CHECK(fixture.contributing.empty());
CHECK(fixture.failed.empty());
CHECK(fixture.timedOut.empty());
CHECK(fixture.timedOutQueries.load() == 0);
CHECK(fixture.queryCount.load() == 0);
CHECK(fixture.avgTime.load() == 0);
}
TEST_CASE("scheduleComponent returns invalid future for zero weight and collect treats it as "
"success",
"[search][fanout][catch2]") {
FanoutFixture fixture;
auto collector = fixture.makeCollector();
auto future =
scheduleComponent(0.0f, std::nullopt, []() -> Result<std::vector<ComponentResult>> {
return std::vector<ComponentResult>{};
});
CHECK_FALSE(future.valid());
const auto status = collector.collect(future, "path", fixture.queryCount, fixture.avgTime);
CHECK(status == ComponentStatus::Success);
CHECK(fixture.componentTiming.empty());
CHECK(fixture.queryCount.load() == 0);
}
TEST_CASE("fanout collector runs synchronously without executor and waits indefinitely on zero "
"timeout",
"[search][fanout][catch2]") {
FanoutFixture fixture;
fixture.config.componentTimeout = std::chrono::milliseconds(0);
auto collector = fixture.makeCollector();
bool ran = false;
auto future =
scheduleComponent(1.0f, std::nullopt, [&ran]() -> Result<std::vector<ComponentResult>> {
ran = true;
return std::vector<ComponentResult>{makeComponent("doc-sync", 0.5f)};
});
CHECK(ran);
const auto status = collector.collect(future, "metadata", fixture.queryCount, fixture.avgTime);
CHECK(status == ComponentStatus::Success);
REQUIRE(fixture.allComponentResults.size() == 1);
CHECK(fixture.allComponentResults.front().documentHash == "doc-sync");
}