350 lines
14 KiB
C++
350 lines
14 KiB
C++
// Catch2 migration of streaming_processor_test.cpp
|
|
// Migration: yams-3s4 (daemon unit tests)
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <map>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/use_future.hpp>
|
|
|
|
#include <yams/daemon/ipc/request_handler.h>
|
|
#include <yams/daemon/ipc/streaming_processor.h>
|
|
|
|
using namespace yams::daemon;
|
|
|
|
namespace {
|
|
|
|
class DummyProcessor : public RequestProcessor {
|
|
public:
|
|
boost::asio::awaitable<Response> process(const Request& request) override {
|
|
if (auto* be = std::get_if<BatchEmbeddingRequest>(&request)) {
|
|
BatchEmbeddingResponse resp;
|
|
resp.dimensions = 1;
|
|
resp.modelUsed = be->modelName;
|
|
resp.processingTimeMs = 0;
|
|
resp.successCount = be->texts.size();
|
|
resp.failureCount = 0;
|
|
resp.embeddings.resize(be->texts.size());
|
|
co_return Response{std::move(resp)};
|
|
}
|
|
if (auto* ed = std::get_if<EmbedDocumentsRequest>(&request)) {
|
|
EmbedDocumentsResponse resp{};
|
|
resp.requested = ed->documentHashes.size();
|
|
resp.embedded = resp.requested;
|
|
resp.skipped = 0;
|
|
resp.failed = 0;
|
|
co_return Response{std::move(resp)};
|
|
}
|
|
co_return Response{SuccessResponse{"ok"}};
|
|
}
|
|
};
|
|
|
|
class SearchProcessor : public RequestProcessor {
|
|
public:
|
|
SearchResponse response;
|
|
std::size_t calls{0};
|
|
|
|
boost::asio::awaitable<Response> process(const Request&) override {
|
|
++calls;
|
|
co_return Response{response};
|
|
}
|
|
};
|
|
|
|
template <typename T> static T get_as(const Response& r) {
|
|
if (auto* v = std::get_if<T>(&r))
|
|
return *v;
|
|
FAIL("Unexpected response variant");
|
|
return T{};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("StreamingProcessor batch embedding streams working and final", "[daemon][streaming]") {
|
|
#ifdef _WIN32
|
|
// Skip on Windows: ProtoSerializer round-trip in streaming processor
|
|
// loses Request variant data due to cross-TU/ODR normalization issues
|
|
SKIP("Streaming processor tests skipped on Windows (protobuf serialization issue)");
|
|
#endif
|
|
// Run io_context synchronously from main thread to avoid coroutine frame destruction races
|
|
boost::asio::io_context io;
|
|
auto delegate = std::make_shared<DummyProcessor>();
|
|
RequestHandler::Config cfg;
|
|
cfg.worker_executor = io.get_executor();
|
|
auto sp = std::make_unique<StreamingRequestProcessor>(delegate, cfg);
|
|
|
|
// Create request and verify variant is valid
|
|
BatchEmbeddingRequest beReq{};
|
|
beReq.modelName = "all-MiniLM-L6-v2";
|
|
beReq.normalize = true;
|
|
beReq.batchSize = 4;
|
|
beReq.texts = {"a", "b", "c", "d", "e", "f", "g", "h", "i"};
|
|
Request request{std::move(beReq)};
|
|
INFO("Test: Request variant index=" << request.index() << " holds_BE="
|
|
<< std::holds_alternative<BatchEmbeddingRequest>(request));
|
|
REQUIRE_FALSE(request.valueless_by_exception());
|
|
REQUIRE(std::holds_alternative<BatchEmbeddingRequest>(request));
|
|
const size_t expectedTextCount = 9;
|
|
|
|
// Spawn coroutine and run io_context until it completes
|
|
bool process_done = false;
|
|
std::optional<Response> opt;
|
|
boost::asio::co_spawn(io, sp->process_streaming(std::move(request)),
|
|
[&](std::exception_ptr, std::optional<Response> result) {
|
|
opt = std::move(result);
|
|
process_done = true;
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
REQUIRE(process_done);
|
|
REQUIRE_FALSE(opt.has_value()); // streaming mode returns nullopt
|
|
|
|
// First chunk: started keepalive or immediate working event
|
|
RequestProcessor::ResponseChunk ch1;
|
|
std::exception_ptr ex1;
|
|
boost::asio::co_spawn(io, sp->next_chunk(),
|
|
[&](std::exception_ptr e, RequestProcessor::ResponseChunk result) {
|
|
ex1 = e;
|
|
if (!e)
|
|
ch1 = std::move(result);
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
if (ex1)
|
|
std::rethrow_exception(ex1);
|
|
if (std::holds_alternative<EmbeddingEvent>(ch1.data)) {
|
|
auto ev = get_as<EmbeddingEvent>(ch1.data);
|
|
REQUIRE_FALSE(ev.modelName.empty());
|
|
}
|
|
|
|
// Drive a couple more chunks; ensure we ultimately get a final BatchEmbeddingResponse
|
|
bool got_final = false;
|
|
for (int i = 0; i < 20 && !got_final; ++i) {
|
|
RequestProcessor::ResponseChunk ch;
|
|
std::exception_ptr ex;
|
|
boost::asio::co_spawn(io, sp->next_chunk(),
|
|
[&](std::exception_ptr e, RequestProcessor::ResponseChunk result) {
|
|
ex = e;
|
|
if (!e)
|
|
ch = std::move(result);
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
if (ex)
|
|
std::rethrow_exception(ex);
|
|
if (std::holds_alternative<BatchEmbeddingResponse>(ch.data)) {
|
|
auto resp = get_as<BatchEmbeddingResponse>(ch.data);
|
|
REQUIRE(resp.successCount == expectedTextCount);
|
|
REQUIRE(ch.is_last_chunk);
|
|
got_final = true;
|
|
}
|
|
}
|
|
REQUIRE(got_final);
|
|
|
|
// Cleanup - all coroutine frames already destroyed since io.run() completed
|
|
sp.reset();
|
|
}
|
|
|
|
TEST_CASE("StreamingProcessor preserves search metadata across response sizes",
|
|
"[daemon][streaming][search_stats]") {
|
|
boost::asio::io_context io;
|
|
auto delegate = std::make_shared<SearchProcessor>();
|
|
RequestHandler::Config cfg;
|
|
cfg.worker_executor = io.get_executor();
|
|
cfg.chunk_size = 512 * 1024;
|
|
std::size_t resultCount = 12;
|
|
std::size_t snippetSize = 8;
|
|
bool expectStreaming = false;
|
|
bool expectMultiplePages = false;
|
|
|
|
SECTION("small response uses the one-shot path") {}
|
|
SECTION("response larger than the default chunk retains metadata") {
|
|
snippetSize = 64 * 1024;
|
|
expectStreaming = true;
|
|
}
|
|
SECTION("metadata survives multiple result pages") {
|
|
cfg.chunk_size = 512;
|
|
expectStreaming = true;
|
|
expectMultiplePages = true;
|
|
}
|
|
SECTION("oversized stats survive an empty result page") {
|
|
resultCount = 0;
|
|
expectStreaming = true;
|
|
delegate->response.searchStats["large_debug_trace"] = std::string(600 * 1024, 'x');
|
|
}
|
|
|
|
auto& expected = delegate->response;
|
|
expected.totalCount = resultCount + 7;
|
|
expected.elapsed = std::chrono::milliseconds(123);
|
|
expected.traceId = "trace-search-stats";
|
|
expected.queryInfo = "hybrid search";
|
|
expected.searchStats["topology_policy"] = "shadow";
|
|
expected.searchStats["vector_exact_distance_evaluations"] = "137";
|
|
for (std::size_t i = 0; i < resultCount; ++i) {
|
|
SearchResult result{};
|
|
result.id = "doc-" + std::to_string(i);
|
|
result.path = "/corpus/" + result.id;
|
|
result.score = 1.0 / (1.0 + i);
|
|
result.snippet = std::string(snippetSize, 's');
|
|
expected.results.push_back(std::move(result));
|
|
}
|
|
|
|
StreamingRequestProcessor processor(delegate, cfg);
|
|
// Reuse the same processor to ensure metadata from the first request cannot leak.
|
|
for (int attempt = 0; attempt < 2; ++attempt) {
|
|
CAPTURE(attempt);
|
|
if (attempt == 1) {
|
|
expected.traceId = "trace-second-search";
|
|
expected.queryInfo = "second query";
|
|
expected.searchStats.clear();
|
|
expected.searchStats["second_request"] = "only";
|
|
if (resultCount == 0) {
|
|
expected.searchStats["large_debug_trace"] = std::string(600 * 1024, 'y');
|
|
}
|
|
}
|
|
Request request{SearchRequest{}};
|
|
auto pending = boost::asio::co_spawn(io, processor.process_streaming(request),
|
|
boost::asio::use_future);
|
|
io.run();
|
|
io.restart();
|
|
auto immediate = pending.get();
|
|
REQUIRE(immediate.has_value() == !expectStreaming);
|
|
|
|
SearchResponse received;
|
|
std::size_t dataPages = 0;
|
|
if (immediate) {
|
|
received = get_as<SearchResponse>(*immediate);
|
|
} else {
|
|
auto starter =
|
|
boost::asio::co_spawn(io, processor.next_chunk(), boost::asio::use_future);
|
|
io.run();
|
|
io.restart();
|
|
auto heartbeat = starter.get();
|
|
REQUIRE_FALSE(heartbeat.is_last_chunk);
|
|
REQUIRE(get_as<SearchResponse>(heartbeat.data).results.empty());
|
|
|
|
bool finished = false;
|
|
// Bounded by the item count, with one final page even for empty results.
|
|
for (std::size_t page = 0; page <= resultCount && !finished; ++page) {
|
|
auto next =
|
|
boost::asio::co_spawn(io, processor.next_chunk(), boost::asio::use_future);
|
|
io.run();
|
|
io.restart();
|
|
auto chunk = next.get();
|
|
auto response = get_as<SearchResponse>(chunk.data);
|
|
REQUIRE(response.totalCount == expected.totalCount);
|
|
REQUIRE(response.elapsed == expected.elapsed);
|
|
if (page == 0) {
|
|
CHECK(response.traceId == expected.traceId);
|
|
CHECK(response.queryInfo == expected.queryInfo);
|
|
CHECK(response.searchStats == expected.searchStats);
|
|
} else {
|
|
CHECK(response.traceId.empty());
|
|
CHECK(response.queryInfo.empty());
|
|
CHECK(response.searchStats.empty());
|
|
}
|
|
received.results.insert(received.results.end(), response.results.begin(),
|
|
response.results.end());
|
|
received.searchStats.insert(response.searchStats.begin(),
|
|
response.searchStats.end());
|
|
if (!response.traceId.empty())
|
|
received.traceId = response.traceId;
|
|
if (!response.queryInfo.empty())
|
|
received.queryInfo = response.queryInfo;
|
|
received.totalCount = response.totalCount;
|
|
received.elapsed = response.elapsed;
|
|
++dataPages;
|
|
finished = chunk.is_last_chunk;
|
|
}
|
|
REQUIRE(finished);
|
|
if (expectMultiplePages)
|
|
REQUIRE(dataPages > 1);
|
|
}
|
|
CHECK(received.searchStats == expected.searchStats);
|
|
CHECK(received.queryInfo == expected.queryInfo);
|
|
CHECK(received.traceId == expected.traceId);
|
|
CHECK(received.totalCount == expected.totalCount);
|
|
CHECK(received.elapsed == expected.elapsed);
|
|
REQUIRE(received.results.size() == expected.results.size());
|
|
for (std::size_t i = 0; i < resultCount; ++i) {
|
|
CHECK(received.results[i].id == expected.results[i].id);
|
|
CHECK(received.results[i].snippet == expected.results[i].snippet);
|
|
CHECK(received.results[i].score == expected.results[i].score);
|
|
}
|
|
REQUIRE(delegate->calls == static_cast<std::size_t>(attempt + 1));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("StreamingProcessor embed documents streams working and final", "[daemon][streaming]") {
|
|
#ifdef _WIN32
|
|
SKIP("Streaming processor tests skipped on Windows (protobuf serialization issue)");
|
|
#endif
|
|
// Run io_context synchronously from main thread to avoid coroutine frame destruction races
|
|
boost::asio::io_context io;
|
|
auto delegate = std::make_shared<DummyProcessor>();
|
|
RequestHandler::Config cfg;
|
|
cfg.worker_executor = io.get_executor();
|
|
auto sp = std::make_unique<StreamingRequestProcessor>(delegate, cfg);
|
|
|
|
// Create request and verify variant is valid
|
|
EmbedDocumentsRequest edReq{};
|
|
edReq.modelName = "all-MiniLM-L6-v2";
|
|
edReq.normalize = true;
|
|
edReq.batchSize = 3;
|
|
edReq.documentHashes = {"h1", "h2", "h3", "h4", "h5", "h6", "h7"};
|
|
Request request{std::move(edReq)};
|
|
INFO("Test: Request variant index=" << request.index() << " holds_ED="
|
|
<< std::holds_alternative<EmbedDocumentsRequest>(request));
|
|
REQUIRE_FALSE(request.valueless_by_exception());
|
|
REQUIRE(std::holds_alternative<EmbedDocumentsRequest>(request));
|
|
const size_t expectedHashCount = 7;
|
|
|
|
// Spawn coroutine and run io_context until it completes
|
|
bool process_done = false;
|
|
std::optional<Response> opt;
|
|
boost::asio::co_spawn(io, sp->process_streaming(std::move(request)),
|
|
[&](std::exception_ptr, std::optional<Response> result) {
|
|
opt = std::move(result);
|
|
process_done = true;
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
REQUIRE(process_done);
|
|
REQUIRE_FALSE(opt.has_value()); // streaming mode returns nullopt
|
|
|
|
// First chunk
|
|
RequestProcessor::ResponseChunk ch1;
|
|
boost::asio::co_spawn(io, sp->next_chunk(),
|
|
[&](std::exception_ptr, RequestProcessor::ResponseChunk result) {
|
|
ch1 = std::move(result);
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
if (std::holds_alternative<EmbeddingEvent>(ch1.data)) {
|
|
auto ev = get_as<EmbeddingEvent>(ch1.data);
|
|
REQUIRE_FALSE(ev.modelName.empty());
|
|
}
|
|
|
|
bool got_final = false;
|
|
for (int i = 0; i < 20 && !got_final; ++i) {
|
|
RequestProcessor::ResponseChunk ch;
|
|
boost::asio::co_spawn(io, sp->next_chunk(),
|
|
[&](std::exception_ptr, RequestProcessor::ResponseChunk result) {
|
|
ch = std::move(result);
|
|
});
|
|
io.run();
|
|
io.restart();
|
|
if (std::holds_alternative<EmbedDocumentsResponse>(ch.data)) {
|
|
auto resp = get_as<EmbedDocumentsResponse>(ch.data);
|
|
REQUIRE(resp.requested == expectedHashCount);
|
|
REQUIRE(ch.is_last_chunk);
|
|
got_final = true;
|
|
}
|
|
}
|
|
REQUIRE(got_final);
|
|
|
|
// Cleanup - all coroutine frames already destroyed since io.run() completed
|
|
sp.reset();
|
|
}
|