yams/tests/benchmarks/search/ingestion_e2e_bench.cpp
Trevon ae15bf317a
perf(ingest): harden daemon batching and shutdown (#58)
* fix(daemon): harden model state ownership

Keep ModelPoolConfig single-defined and serialize telemetry snapshots to avoid ODR drift and torn concurrent reads.

* refactor(daemon): consolidate ingest pollers

Require the wake-timer contract so every post-ingest stage shares one event-driven poller configuration.

* refactor(daemon): unify repair execution

Share admission, operation ordering, cancellation, progress, and result aggregation between synchronous and asynchronous repair paths.

* refactor(daemon): unify runtime tuning

Resolve startup and reload settings through one typed path so parsing, precedence, and range validation cannot drift.

* refactor(daemon)!: remove dead IPC surfaces

Delete unreferenced framing, cancellation, and compatibility paths while keeping the active streaming protocol intact.

BREAKING CHANGE: callers using connectAsync or legacy MessageFramer and FrameReader helpers must migrate to connect, frame_message_into, feed, and get_frame.

* refactor(daemon): hide test hooks

Preserve test-library linkage without exposing daemon-only probes through production headers or external symbols.

* fix(daemon): avoid replaying successful adds

A fresh-connection transport retry reuses the response handler. Clear its stale EOF error when the retry returns a valid add response so the outer retry loop does not enqueue the document again.

* bench(ingest): enforce lossless pipeline runs

Separate admission, storage, drain, enrichment, and searchability timing. Decision-grade plans now reject drops, duplicate processing, incomplete pipelines, and experiment identity drift.

* bench(search): expose cold seed ingest timings

Pair generalized-memory quality arms with admission, storage, drain, searchability, and enrichment timing from their shared cold seed. Keep the observation distinct from repeated throughput measurements.

* perf(ingest): batch deferred content indexing

Directory ingestion already schedules content work through post-ingest. Avoid duplicate inline indexing and coalesce normal-priority trickles within a bounded window.

* feat(metadata): expose insert writer metrics

Separate queue, batch-apply, and transaction time so ingestion runs can attribute connection pressure without changing write semantics.

* fix(daemon): retry transient refused connects

The daemon socket can be bound before its listener is ready during startup. Reuse the existing bounded retry budget for that refusal window.

* bench(ingest): attribute submission path costs

Decision-grade runs need stable coalescing identity and separate queue, apply, transaction, and estimated connection-wait costs.

* perf(ingest): batch durable document storage

Commit chunks, manifests, references, and metadata in bounded batches while preserving publication only after durable reference success.

* perf(ingest): coalesce bulk post-processing

Use a 16-item bulk cap while keeping latency-sensitive daemon ingestion on the existing four-item priority quota.

* bench(ingest): record post-ingest batch size

Include the effective cap in metrics and experiment identity so runs with different batching policies cannot be compared as one system.

* perf(ingest): batch queued storage waves

Preserve single-document fallback semantics and execute inline when the coordinator has one worker to avoid self-deadlock.

* perf(ingest): route bulk enrichment batches

Keep quota-sized interactive groups on the RPC lane while sending larger lossless groups through bounded coalescing.

* test(daemon): initialize task fixtures

* perf(ingest): batch post-ingest dispatch

* perf(ingest): widen directory storage waves

Borrow from a full wave when one document would otherwise fall through to single-document storage.

* perf(storage): bound batch writer fanout

Reuse a fixed worker set so batch size no longer creates one thread and payload copy per object.

* perf(ingest): align storage batch drains

Dispatch one configured batch through storage so intermediate waves do not fragment downstream metadata work.

* fix(ingest): commit metadata state atomically

Fold first-version, extracted-title, and embedding-completion state
into existing transactions.

Read committed extraction state so duplicate-content tasks cannot
double-count corpus counters.

* bench(ingest): measure production submission

Drive pipelined files through DocumentIngestionService.
Export dispatch, fingerprint, and enqueue costs through xplan.

* fix(xplan): preserve native failures

Report child signals and bounded stderr before readiness checks so
assertions are not masked as missing traces.

* fix(daemon): preserve embedding success hashes

* fix(daemon): serialize pooled socket shutdown

* fix(daemon): harden batch publication shutdown

* fix(storage): use compat jthread shim

* fix(daemon): avoid poller template shadowing

* fix(search): keep vector pipeline test enabled

* fix: harden shutdown and fallback paths

Demote expected shutdown SQL interruptions, ignore readonly FTS integrity noise, avoid stopped-executor socket hangs, and preserve metadata writer fallback outcomes.
2026-07-17 07:04:30 -04:00

1702 lines
75 KiB
C++

/*
ingestion_e2e_bench.cpp - End-to-End Ingestion Pipeline Benchmark (PBI-004)
Measures complete ingestion pipeline performance with per-stage timing,
queue depth monitoring, and throughput characterization.
Usage:
YAMS_TEST_SAFE_SINGLE_INSTANCE=1 ./builddir/tests/benchmarks/ingestion_e2e_bench
Environment variables:
YAMS_TEST_SAFE_SINGLE_INSTANCE=1 - Required to prevent GlobalIOContext reset
YAMS_BENCH_CORPUS_SIZE=N - Number of documents to ingest (default: 100)
YAMS_BENCH_DOC_SIZE=N - Document size in bytes (default: 1000)
YAMS_BENCH_POLL_INTERVAL_MS=N - Queue polling interval (default: 100)
YAMS_BENCH_INGEST_MODE=MODE - directory, pipelined_single_file, or
single_file_serial (default)
YAMS_BENCH_INGEST_CONCURRENCY=N - In-flight window for pipelined_single_file
(default: 4)
YAMS_BENCH_POST_INGEST_COALESCE_MS=N
- Typed post-ingest coalescing window (default: 2)
YAMS_BENCH_POST_INGEST_BATCH_SIZE=N
- Existing typed batch-size axis (default: product value)
YAMS_BENCH_OUTPUT=path - JSON output file (default: stdout)
YAMS_BENCH_DISABLE_KG=1 - Disable KG post-ingest enrichment for ablation
YAMS_BENCH_SEARCH_PROBES=0 - Disable fixed post-ingest search probes
Metrics collected:
- Total pipeline duration (start → all stages complete)
- Per-stage latency: metadata, FTS5, embeddings, KG
- Queue depths over time (store_document_tasks, embed_jobs, fts5_jobs, postIngest)
- Throughput (docs/sec) and success/fail counts
- WorkCoordinator thread pool utilization
- Fixed post-ingest keyword/semantic/hybrid search probe impact
*/
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <fmt/ranges.h>
#include "tests/integration/daemon/test_async_helpers.h"
#include <boost/asio/awaitable.hpp>
#include <boost/asio/io_context.hpp>
#include <yams/api/content_store.h>
#include <yams/app/services/document_ingestion_service.h>
#include <yams/app/services/services.hpp>
#include <yams/common/fs_utils.h>
#include <yams/daemon/client/daemon_client.h>
#include <yams/daemon/client/global_io_context.h>
#include <yams/daemon/components/InternalEventBus.h>
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/components/WriteCoordinator.h>
#include <yams/daemon/daemon.h>
#include <yams/metadata/metadata_repository.h>
#include <yams/storage/reference_counter.h>
#include <yams/storage/reference_counter_writer.h>
#include <yams/vector/simeon_embedding_backend.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <memory>
#include <random>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include <yams/compat/unistd.h>
namespace fs = std::filesystem;
using namespace std::chrono_literals;
using json = nlohmann::json;
namespace {
int64_t nonNegativeDeltaMs(int64_t endMs, int64_t startMs) noexcept {
return endMs > startMs ? endMs - startMs : 0;
}
uint64_t counterDelta(uint64_t current, uint64_t baseline) noexcept {
return current > baseline ? current - baseline : 0;
}
uint64_t saturatedAdd(uint64_t lhs, uint64_t rhs) noexcept {
if (lhs > std::numeric_limits<uint64_t>::max() - rhs) {
return std::numeric_limits<uint64_t>::max();
}
return lhs + rhs;
}
bool envFlagEnabled(const char* name) {
const char* raw = std::getenv(name);
if (!raw || !*raw) {
return false;
}
std::string value(raw);
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value == "1" || value == "true" || value == "yes" || value == "on";
}
std::uint32_t benchPostIngestCoalesceMs() {
constexpr std::uint32_t kMaxCoalesceMs = 20;
const auto defaultValue = yams::daemon::TuningConfig{}.postIngestCoalesceMs;
const char* raw = std::getenv("YAMS_BENCH_POST_INGEST_COALESCE_MS");
if (!raw || !*raw) {
return defaultValue;
}
try {
const auto parsed = std::stoul(raw);
return parsed <= kMaxCoalesceMs ? static_cast<std::uint32_t>(parsed) : defaultValue;
} catch (const std::exception&) {
return defaultValue;
}
}
std::uint32_t benchPostIngestBatchSize() {
constexpr std::uint32_t kMaxBatchSize = 256;
const auto defaultValue = yams::daemon::TuneAdvisor::postIngestBatchSize();
const char* raw = std::getenv("YAMS_BENCH_POST_INGEST_BATCH_SIZE");
if (!raw || !*raw) {
return defaultValue;
}
try {
const auto parsed = std::stoul(raw);
return parsed >= 1 && parsed <= kMaxBatchSize ? static_cast<std::uint32_t>(parsed)
: defaultValue;
} catch (const std::exception&) {
return defaultValue;
}
}
} // namespace
// ============================================================================
// SimpleDaemonHarness - Minimal daemon lifecycle for benchmarking
// ============================================================================
class SimpleDaemonHarness {
public:
SimpleDaemonHarness() {
auto id = randomId();
root_ = fs::temp_directory_path() / ("yams_e2e_bench_" + id);
yams::common::ensureDirectories(root_);
data_ = root_ / "data";
yams::common::ensureDirectories(data_);
sock_ = fs::path("/tmp") / ("e2e_bench_" + id + ".sock");
pid_ = root_ / "bench.pid";
log_ = root_ / "bench.log";
}
~SimpleDaemonHarness() {
stop();
cleanup();
}
bool start() {
// Save original working directory and change to temp dir
// This prevents session auto-watch from indexing CWD
originalCwd_ = fs::current_path();
fs::current_path(root_);
// Also isolate session state to prevent global session watch from triggering
originalXdgState_ = std::getenv("XDG_STATE_HOME") ? std::getenv("XDG_STATE_HOME") : "";
setenv("XDG_STATE_HOME", (root_ / "state").string().c_str(), 1);
yams::common::ensureDirectories(root_ / "state" / "yams" / "sessions");
yams::daemon::DaemonConfig cfg;
cfg.dataDir = data_;
cfg.socketPath = sock_;
cfg.pidFile = pid_;
cfg.logFile = log_;
cfg.enableModelProvider = true;
// Disable auto-repair during benchmarks to avoid background work (per-hash DB checks,
// vector cleanup, etc.) affecting ingestion measurements.
cfg.enableAutoRepair = false;
cfg.tuning.postIngestCoalesceMs = benchPostIngestCoalesceMs();
// Benchmark embedding-mode contract:
// - YAMS_BENCH_FORCE_MOCK_EMBEDDINGS=1 forces mock model provider
// - YAMS_DISABLE_VECTORS=1 also implies mock/FTS-only mode
const char* forceMockEmbeddings = std::getenv("YAMS_BENCH_FORCE_MOCK_EMBEDDINGS");
const bool mockEmbeddingsRequested =
forceMockEmbeddings && std::string(forceMockEmbeddings) == "1";
const char* disableVectors = std::getenv("YAMS_DISABLE_VECTORS");
bool vectorsDisabled = disableVectors && std::string(disableVectors) == "1";
vectorsDisabled = vectorsDisabled || mockEmbeddingsRequested;
const char* benchModelEnv = std::getenv("YAMS_BENCH_EMBED_MODEL");
std::string benchModel = benchModelEnv && *benchModelEnv ? benchModelEnv : "simeon-default";
const bool useSimeon = benchModel == "simeon-default" || benchModel == "simeon";
if (useSimeon && !std::getenv("YAMS_EMBED_BACKEND")) {
setenv("YAMS_EMBED_BACKEND", "simeon", 1);
}
if (vectorsDisabled) {
cfg.useMockModelProvider = true;
cfg.autoLoadPlugins = false;
spdlog::info("Using mock model provider (YAMS_BENCH_FORCE_MOCK_EMBEDDINGS={} "
"YAMS_DISABLE_VECTORS={})",
mockEmbeddingsRequested ? 1 : 0, disableVectors ? disableVectors : "0");
} else if (useSimeon) {
// Simeon is built in and deterministic, so it is the preferred default for
// ingestion profiling. Avoid plugin startup and ONNX model load noise.
cfg.useMockModelProvider = false;
cfg.autoLoadPlugins = false;
cfg.modelPoolConfig.lazyLoading = false;
cfg.modelPoolConfig.preloadModels = {"simeon-default"};
spdlog::info("Using Simeon embedding provider for ingestion benchmark");
} else {
// Use real model provider with plugins for accurate end-to-end benchmark
cfg.useMockModelProvider = false;
cfg.autoLoadPlugins = true;
// Configure model pool for real embedding generation
cfg.modelPoolConfig.lazyLoading = false;
cfg.modelPoolConfig.preloadModels = {benchModel};
}
daemon_ = std::make_unique<yams::daemon::YamsDaemon>(cfg);
auto s = daemon_->start();
if (!s) {
spdlog::error("Failed to start daemon: {}", s.error().message);
return false;
}
// Start runLoop in background thread - CRITICAL for processing requests
runLoopThread_ = std::thread([this]() { daemon_->runLoop(); });
auto deadline = std::chrono::steady_clock::now() + 30s;
while (std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms);
auto lifecycle = daemon_->getLifecycle().snapshot();
if (lifecycle.state == yams::daemon::LifecycleState::Ready) {
spdlog::info("Daemon ready at socket: {}", sock_.string());
if (!vectorsDisabled) {
if (auto* sm = daemon_->getServiceManager()) {
auto ready = sm->ensureEmbeddingModelReadySync(
useSimeon ? "simeon-default" : benchModel, {}, 10000, false, false);
if (!ready) {
spdlog::warn("Embedding model not ready: {}", ready.error().message);
}
}
}
return true;
}
if (lifecycle.state == yams::daemon::LifecycleState::Failed) {
spdlog::error("Daemon failed to start");
return false;
}
}
spdlog::error("Daemon startup timeout");
return false;
}
void stop() {
if (daemon_) {
spdlog::info("[SimpleDaemonHarness] Stopping daemon (running={})...",
daemon_->isRunning());
auto stopResult = daemon_->stop();
if (!stopResult) {
spdlog::warn("[SimpleDaemonHarness] Daemon stop returned error: {}",
stopResult.error().message);
}
// Join the runLoop thread before continuing
if (runLoopThread_.joinable()) {
runLoopThread_.join();
}
// Wait for daemon to report it's no longer running
int isRunningRetries = 0;
while (daemon_->isRunning() && isRunningRetries < 50) {
std::this_thread::sleep_for(10ms);
isRunningRetries++;
}
if (isRunningRetries > 0) {
spdlog::info("[SimpleDaemonHarness] Waited {}ms for isRunning() to become false",
isRunningRetries * 10);
}
spdlog::info("[SimpleDaemonHarness] Daemon stopped (running={}), resetting instance...",
daemon_->isRunning());
// Reset GlobalIOContext to clean up threads and io_context state
// Temporarily unset YAMS_TESTING to allow reset() to actually work
const char* yams_testing = std::getenv("YAMS_TESTING");
const char* yams_safe = std::getenv("YAMS_TEST_SAFE_SINGLE_INSTANCE");
if (yams_testing) {
unsetenv("YAMS_TESTING");
}
if (yams_safe) {
unsetenv("YAMS_TEST_SAFE_SINGLE_INSTANCE");
}
yams::daemon::GlobalIOContext::reset();
spdlog::info("[SimpleDaemonHarness] GlobalIOContext reset complete");
// Restore environment variables
if (yams_testing) {
setenv("YAMS_TESTING", yams_testing, 1);
}
if (yams_safe) {
setenv("YAMS_TEST_SAFE_SINGLE_INSTANCE", yams_safe, 1);
}
daemon_.reset();
// Allow OS to fully release thread resources (macOS needs this)
// Real model provider + plugins create additional threads beyond base daemon
std::this_thread::sleep_for(500ms);
}
// Restore original working directory
if (!originalCwd_.empty()) {
std::error_code ec;
fs::current_path(originalCwd_, ec);
}
// Restore original XDG_STATE_HOME
if (originalXdgState_.empty()) {
unsetenv("XDG_STATE_HOME");
} else {
setenv("XDG_STATE_HOME", originalXdgState_.c_str(), 1);
}
}
const fs::path& socketPath() const { return sock_; }
const fs::path& root() const { return root_; }
const fs::path& dataDir() const { return data_; }
yams::daemon::YamsDaemon* daemon() const { return daemon_.get(); }
private:
static std::string randomId() {
static const char* cs = "abcdefghijklmnopqrstuvwxyz0123456789";
thread_local std::mt19937_64 rng{std::random_device{}()};
std::uniform_int_distribution<size_t> dist(0, 35);
std::string out;
for (int i = 0; i < 8; ++i)
out.push_back(cs[dist(rng)]);
return out;
}
void cleanup() {
std::error_code ec;
if (!root_.empty())
fs::remove_all(root_, ec);
}
std::unique_ptr<yams::daemon::YamsDaemon> daemon_;
std::thread runLoopThread_;
fs::path root_, data_, sock_, pid_, log_;
fs::path originalCwd_;
std::string originalXdgState_;
};
// ============================================================================
// CorpusGenerator - Generate synthetic documents for benchmarking
// ============================================================================
struct CorpusGenerator {
static constexpr std::uint32_t kSeed = 42;
static constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
static constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
fs::path corpusDir;
std::vector<std::string> createdFiles;
std::mt19937 rng{kSeed};
size_t docSize;
std::uint64_t fingerprintValue{kFnvOffsetBasis};
CorpusGenerator(const fs::path& dir, size_t size) : corpusDir(dir), docSize(size) {
yams::common::ensureDirectories(corpusDir);
}
void hashBytes(std::string_view bytes) {
for (const unsigned char byte : bytes) {
fingerprintValue ^= byte;
fingerprintValue *= kFnvPrime;
}
}
std::string fingerprint() const {
std::ostringstream out;
out << "fnv1a64:" << std::hex << std::setfill('0') << std::setw(16) << fingerprintValue;
return out.str();
}
void generateDocuments(int count) {
spdlog::info("Generating {} documents of ~{} bytes each", count, docSize);
std::uniform_int_distribution<int> wordDist(0, 999);
for (int i = 0; i < count; ++i) {
std::ostringstream content;
content << "Document " << i << " - Synthetic Test Corpus\n\n";
// Generate content to approximately match docSize
size_t written = content.str().size();
while (written < docSize) {
content << "word" << wordDist(rng) << " ";
written += 10; // Approximate word length
if (written % 100 == 0) {
content << "\n";
}
}
std::string filename = "doc_" + std::to_string(i) + ".txt";
const std::string document = content.str();
std::ofstream(corpusDir / filename) << document;
hashBytes(filename);
hashBytes(document);
createdFiles.push_back(filename);
if ((i + 1) % 10 == 0 || (i + 1) == count) {
spdlog::debug("Generated {}/{} documents", i + 1, count);
}
}
spdlog::info("Corpus generation complete: {} files", createdFiles.size());
}
};
// ============================================================================
// QueueSnapshot - Capture queue depths and counters at a point in time
// Uses InternalEventBus::instance() to access daemon-internal state
// ============================================================================
struct QueueSnapshot {
int64_t timestamp_ms = 0;
// Queue depths (actual items in queues)
uint64_t store_document_tasks = 0;
uint64_t embed_jobs = 0;
uint64_t fts5_jobs = 0;
uint64_t post_ingest = 0;
// Cumulative counters (for stage completion detection)
uint64_t embed_queued = 0;
uint64_t embed_consumed = 0;
uint64_t embed_dropped = 0;
uint64_t embed_prepared_docs_queued = 0;
uint64_t embed_prepared_chunks_queued = 0;
uint64_t embed_hash_only_docs_queued = 0;
uint64_t kg_jobs = 0;
uint64_t symbol_jobs = 0;
uint64_t entity_jobs = 0;
uint64_t title_jobs = 0;
uint64_t fts5_queued = 0;
uint64_t fts5_consumed = 0;
uint64_t fts5_dropped = 0;
uint64_t post_queued = 0;
uint64_t post_consumed = 0;
uint64_t post_dropped = 0;
uint64_t kg_queued = 0;
uint64_t kg_consumed = 0;
uint64_t kg_dropped = 0;
uint64_t symbol_queued = 0;
uint64_t symbol_consumed = 0;
uint64_t symbol_dropped = 0;
uint64_t entity_queued = 0;
uint64_t entity_consumed = 0;
uint64_t entity_dropped = 0;
uint64_t title_queued = 0;
uint64_t title_consumed = 0;
uint64_t title_dropped = 0;
json toJson() const {
return json{
{"timestamp_ms", timestamp_ms},
{"queue_depths",
{{"store_document_tasks", store_document_tasks},
{"embed_jobs", embed_jobs},
{"fts5_jobs", fts5_jobs},
{"post_ingest", post_ingest}}},
{"enrichment_depths",
{{"kg_jobs", kg_jobs},
{"symbol_jobs", symbol_jobs},
{"entity_jobs", entity_jobs},
{"title_jobs", title_jobs}}},
{"counters",
{{"embed",
{{"queued", embed_queued},
{"consumed", embed_consumed},
{"dropped", embed_dropped},
{"prepared_docs_queued", embed_prepared_docs_queued},
{"prepared_chunks_queued", embed_prepared_chunks_queued},
{"hash_only_docs_queued", embed_hash_only_docs_queued}}},
{"fts5",
{{"queued", fts5_queued}, {"consumed", fts5_consumed}, {"dropped", fts5_dropped}}},
{"post",
{{"queued", post_queued}, {"consumed", post_consumed}, {"dropped", post_dropped}}},
{"kg", {{"queued", kg_queued}, {"consumed", kg_consumed}, {"dropped", kg_dropped}}},
{"symbol",
{{"queued", symbol_queued},
{"consumed", symbol_consumed},
{"dropped", symbol_dropped}}},
{"entity",
{{"queued", entity_queued},
{"consumed", entity_consumed},
{"dropped", entity_dropped}}},
{"title",
{{"queued", title_queued},
{"consumed", title_consumed},
{"dropped", title_dropped}}}}}};
}
};
// ============================================================================
// StageMetrics - Per-stage completion tracking
// ============================================================================
struct StageMetrics {
std::string name;
int64_t start_ms = 0;
int64_t end_ms = 0;
uint64_t count = 0;
uint64_t failures = 0;
int64_t duration_ms() const { return nonNegativeDeltaMs(end_ms, start_ms); }
json toJson() const {
return json{{"name", name},
{"duration_ms", duration_ms()},
{"count", count},
{"failures", failures}};
}
};
struct SearchImpactProbe {
std::string name;
std::string query;
std::string search_type;
bool required_by_contract = false;
bool skipped = false;
std::string skip_reason{};
bool ok = false;
std::string error{};
int64_t wall_ms = 0;
int64_t completed_ms = 0;
int64_t daemon_elapsed_ms = 0;
uint64_t total_count = 0;
uint64_t returned_count = 0;
std::vector<std::string> top_ids{};
json toJson() const {
return json{{"name", name},
{"query", query},
{"search_type", search_type},
{"required_by_contract", required_by_contract},
{"skipped", skipped},
{"skip_reason", skip_reason},
{"ok", ok},
{"error", error},
{"wall_ms", wall_ms},
{"completed_ms", completed_ms},
{"daemon_elapsed_ms", daemon_elapsed_ms},
{"total_count", total_count},
{"returned_count", returned_count},
{"top_ids", top_ids}};
}
};
struct AddDispatchMetrics {
uint64_t samples = 0;
uint64_t total_us = 0;
uint64_t max_us = 0;
uint64_t fingerprint_total_us = 0;
uint64_t fingerprint_max_us = 0;
uint64_t enqueue_total_us = 0;
uint64_t enqueue_max_us = 0;
json toJson() const {
const auto average = [this](uint64_t total) {
return samples == 0 ? 0.0 : static_cast<double>(total) / static_cast<double>(samples);
};
return json{{"samples", samples},
{"total_us", total_us},
{"max_us", max_us},
{"avg_us", average(total_us)},
{"fingerprint_total_us", fingerprint_total_us},
{"fingerprint_max_us", fingerprint_max_us},
{"fingerprint_avg_us", average(fingerprint_total_us)},
{"enqueue_total_us", enqueue_total_us},
{"enqueue_max_us", enqueue_max_us},
{"enqueue_avg_us", average(enqueue_total_us)}};
}
};
// ============================================================================
// BenchmarkResult - Complete benchmark output
// ============================================================================
struct BenchmarkResult {
// Test configuration
int corpus_size = 0;
int doc_size = 0;
int poll_interval_ms = 0;
std::uint32_t corpus_seed = CorpusGenerator::kSeed;
std::string corpus_fingerprint;
std::string ingest_mode = "single_file_serial";
std::size_t ingest_concurrency = 1;
std::uint32_t post_ingest_coalesce_ms = 0;
std::uint32_t post_ingest_batch_size = 0;
std::string timestamp;
std::string embedding_model = "simeon-default";
std::string embedding_backend = "simeon";
std::string simeon_recipe;
bool kg_enabled = true;
// Overall metrics
int64_t total_duration_ms = 0;
double throughput_docs_per_sec = 0.0;
bool pipeline_complete = false;
uint64_t expected_post = 0;
uint64_t expected_embed = 0;
uint64_t expected_kg = 0;
uint64_t observed_post = 0;
uint64_t observed_embed = 0;
uint64_t observed_kg = 0;
bool stages_settled = false;
bool enrichment_drained = false;
bool write_coordinator_flushed = false;
int64_t admission_ms = 0;
int64_t storage_ready_ms = 0;
int64_t pipeline_drain_ms = 0;
int64_t enrichment_ready_ms = 0;
int64_t searchability_ready_ms = 0;
AddDispatchMetrics add_dispatch_metrics;
// Per-stage metrics
StageMetrics metadata_storage;
StageMetrics fts5_extraction;
StageMetrics embedding_generation;
StageMetrics kg_extraction;
std::unordered_map<std::string, yams::daemon::EmbeddingService::PhaseTiming>
embedding_phase_timings;
std::unordered_map<std::string, yams::app::services::DocumentStorePhaseTiming>
document_store_phase_timings;
std::unordered_map<std::string, yams::api::ContentStorePhaseTiming> content_store_phase_timings;
std::unordered_map<std::string, yams::metadata::MetadataInsertPhaseTiming>
metadata_insert_phase_timings;
std::unordered_map<std::string, yams::storage::RefCounterCommitPhaseTiming>
ref_counter_commit_phase_timings;
std::unordered_map<std::string, yams::storage::RefCounterWriterTiming>
ref_counter_writer_timings;
std::unordered_map<std::string, yams::storage::RefCounterWriterValueMetric>
ref_counter_writer_value_metrics;
yams::metadata::MetadataInsertWriter::MetricsSnapshot metadata_insert_writer_metrics;
yams::daemon::PostIngestQueue::MetricsSnapshot post_ingest_metrics;
std::vector<SearchImpactProbe> search_impact;
// Queue monitoring
std::vector<QueueSnapshot> queue_samples;
uint64_t max_store_document_tasks = 0;
uint64_t max_embed_jobs = 0;
uint64_t max_fts5_jobs = 0;
uint64_t max_post_ingest = 0;
uint64_t max_kg_jobs = 0;
uint64_t max_symbol_jobs = 0;
uint64_t max_entity_jobs = 0;
uint64_t max_title_jobs = 0;
uint64_t final_kg_queued = 0;
uint64_t final_kg_consumed = 0;
uint64_t final_kg_dropped = 0;
uint64_t final_symbol_queued = 0;
uint64_t final_symbol_consumed = 0;
uint64_t final_symbol_dropped = 0;
uint64_t final_entity_queued = 0;
uint64_t final_entity_consumed = 0;
uint64_t final_entity_dropped = 0;
uint64_t final_title_queued = 0;
uint64_t final_title_consumed = 0;
uint64_t final_title_dropped = 0;
uint64_t dropped_batches = 0;
// WorkCoordinator metrics
size_t thread_count = 0;
bool has_write_coordinator_stats = false;
yams::daemon::WriteCoordinator::Stats write_coordinator_stats;
json toJson() const {
json j;
j["test_config"] = {{"corpus_size", corpus_size},
{"doc_size", doc_size},
{"poll_interval_ms", poll_interval_ms},
{"corpus_seed", corpus_seed},
{"corpus_fingerprint", corpus_fingerprint},
{"ingest_mode", ingest_mode},
{"ingest_concurrency", ingest_concurrency},
{"post_ingest_coalesce_ms", post_ingest_coalesce_ms},
{"post_ingest_batch_size", post_ingest_batch_size},
{"timestamp", timestamp},
{"embedding_model", embedding_model},
{"embedding_backend", embedding_backend},
{"simeon_recipe", simeon_recipe},
{"kg_enabled", kg_enabled}};
j["total_duration_ms"] = total_duration_ms;
j["throughput_docs_per_sec"] = throughput_docs_per_sec;
j["pipeline_status"] = {{"complete", pipeline_complete},
{"stages_settled", stages_settled},
{"enrichment_drained", enrichment_drained},
{"write_coordinator_flushed", write_coordinator_flushed},
{"expected_post", expected_post},
{"observed_post", observed_post},
{"expected_embed", expected_embed},
{"observed_embed", observed_embed},
{"expected_kg", expected_kg},
{"observed_kg", observed_kg}};
j["phase_timings"] = {{"admission_ms", admission_ms},
{"storage_ready_ms", storage_ready_ms},
{"pipeline_drain_ms", pipeline_drain_ms},
{"enrichment_ready_ms", enrichment_ready_ms},
{"searchability_ready_ms", searchability_ready_ms}};
j["add_dispatch_metrics"] = add_dispatch_metrics.toJson();
j["stages"] = {{"metadata_storage", metadata_storage.toJson()},
{"fts5_extraction", fts5_extraction.toJson()},
{"embedding_generation", embedding_generation.toJson()},
{"kg_extraction", kg_extraction.toJson()}};
json phaseTimings = json::object();
for (const auto& [phase, timing] : embedding_phase_timings) {
phaseTimings[phase] = {{"calls", timing.calls},
{"total_ms", timing.totalMs},
{"max_ms", timing.maxMs},
{"avg_ms", timing.calls == 0
? 0.0
: static_cast<double>(timing.totalMs) /
static_cast<double>(timing.calls)}};
}
j["embedding_phase_timings"] = std::move(phaseTimings);
json docStoreTimings = json::object();
for (const auto& [phase, timing] : document_store_phase_timings) {
docStoreTimings[phase] = {{"calls", timing.calls},
{"total_ms", timing.totalMs},
{"max_ms", timing.maxMs},
{"avg_ms", timing.calls == 0
? 0.0
: static_cast<double>(timing.totalMs) /
static_cast<double>(timing.calls)}};
}
j["document_store_phase_timings"] = std::move(docStoreTimings);
json contentStoreTimings = json::object();
for (const auto& [phase, timing] : content_store_phase_timings) {
contentStoreTimings[phase] = {
{"calls", timing.calls},
{"total_ms", timing.totalMs},
{"max_ms", timing.maxMs},
{"avg_ms", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalMs) /
static_cast<double>(timing.calls)},
{"total_us", timing.totalUs},
{"max_us", timing.maxUs},
{"avg_us", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalUs) /
static_cast<double>(timing.calls)}};
}
j["content_store_phase_timings"] = std::move(contentStoreTimings);
json metadataInsertTimings = json::object();
for (const auto& [phase, timing] : metadata_insert_phase_timings) {
metadataInsertTimings[phase] = {
{"calls", timing.calls},
{"total_us", timing.totalUs},
{"max_us", timing.maxUs},
{"avg_us", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalUs) /
static_cast<double>(timing.calls)}};
}
j["metadata_insert_phase_timings"] = std::move(metadataInsertTimings);
j["metadata_insert_writer_metrics"] = {
{"submitted_items", metadata_insert_writer_metrics.submittedItems},
{"completed_items", metadata_insert_writer_metrics.completedItems},
{"rejected_items", metadata_insert_writer_metrics.rejectedItems},
{"batches", metadata_insert_writer_metrics.batches},
{"batch_items", metadata_insert_writer_metrics.batchItems},
{"max_batch_size", metadata_insert_writer_metrics.maxBatchSize},
{"avg_batch_size",
metadata_insert_writer_metrics.batches == 0
? 0.0
: static_cast<double>(metadata_insert_writer_metrics.batchItems) /
static_cast<double>(metadata_insert_writer_metrics.batches)},
{"queue_wait_samples", metadata_insert_writer_metrics.queueWaitSamples},
{"queue_wait_total_us", metadata_insert_writer_metrics.queueWaitTotalUs},
{"queue_wait_max_us", metadata_insert_writer_metrics.queueWaitMaxUs},
{"queue_wait_avg_us",
metadata_insert_writer_metrics.queueWaitSamples == 0
? 0.0
: static_cast<double>(metadata_insert_writer_metrics.queueWaitTotalUs) /
static_cast<double>(metadata_insert_writer_metrics.queueWaitSamples)},
{"batch_apply_samples", metadata_insert_writer_metrics.batchApplySamples},
{"batch_apply_total_us", metadata_insert_writer_metrics.batchApplyTotalUs},
{"batch_apply_max_us", metadata_insert_writer_metrics.batchApplyMaxUs},
{"batch_apply_avg_us",
metadata_insert_writer_metrics.batchApplySamples == 0
? 0.0
: static_cast<double>(metadata_insert_writer_metrics.batchApplyTotalUs) /
static_cast<double>(metadata_insert_writer_metrics.batchApplySamples)},
{"failed_batches", metadata_insert_writer_metrics.failedBatches},
{"fallback_items", metadata_insert_writer_metrics.fallbackItems}};
json refCounterCommitTimings = json::object();
for (const auto& [phase, timing] : ref_counter_commit_phase_timings) {
refCounterCommitTimings[phase] = {
{"calls", timing.calls},
{"total_us", timing.totalUs},
{"max_us", timing.maxUs},
{"avg_us", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalUs) /
static_cast<double>(timing.calls)}};
}
j["ref_counter_commit_phase_timings"] = std::move(refCounterCommitTimings);
json refCounterWriterTimings = json::object();
for (const auto& [phase, timing] : ref_counter_writer_timings) {
refCounterWriterTimings[phase] = {
{"calls", timing.calls},
{"total_us", timing.totalUs},
{"max_us", timing.maxUs},
{"avg_us", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalUs) /
static_cast<double>(timing.calls)}};
}
j["ref_counter_writer_timings"] = std::move(refCounterWriterTimings);
json refCounterWriterValueMetrics = json::object();
for (const auto& [metric, value] : ref_counter_writer_value_metrics) {
refCounterWriterValueMetrics[metric] = {
{"calls", value.calls},
{"total", value.total},
{"max", value.max},
{"avg", value.calls == 0
? 0.0
: static_cast<double>(value.total) / static_cast<double>(value.calls)}};
}
j["ref_counter_writer_value_metrics"] = std::move(refCounterWriterValueMetrics);
json postTimings = json::object();
for (const auto& [phase, timing] : post_ingest_metrics.timings) {
postTimings[phase] = {
{"calls", timing.calls},
{"total_ms", timing.totalMs},
{"max_ms", timing.maxMs},
{"avg_ms", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalMs) /
static_cast<double>(timing.calls)},
{"total_us", timing.totalUs},
{"max_us", timing.maxUs},
{"avg_us", timing.calls == 0 ? 0.0
: static_cast<double>(timing.totalUs) /
static_cast<double>(timing.calls)},
{"first_start_ms", timing.firstStartMs},
{"last_end_ms", timing.lastEndMs},
{"span_ms", nonNegativeDeltaMs(timing.lastEndMs, timing.firstStartMs)}};
}
j["post_ingest_phase_timings"] = std::move(postTimings);
json searchImpact = json::array();
for (const auto& probe : search_impact) {
searchImpact.push_back(probe.toJson());
}
j["search_impact"] = std::move(searchImpact);
j["post_ingest_batch_metrics"] = {
{"extraction_batches", post_ingest_metrics.batches.extractionBatches},
{"extraction_tasks", post_ingest_metrics.batches.extractionTasks},
{"extraction_successes", post_ingest_metrics.batches.extractionSuccesses},
{"extraction_failures", post_ingest_metrics.batches.extractionFailures},
{"embed_jobs_emitted", post_ingest_metrics.batches.embedJobsEmitted},
{"embed_docs_emitted", post_ingest_metrics.batches.embedDocsEmitted},
{"embed_prepared_docs_emitted", post_ingest_metrics.batches.embedPreparedDocsEmitted},
{"embed_hash_only_docs_emitted", post_ingest_metrics.batches.embedHashOnlyDocsEmitted},
{"content_index_calls", post_ingest_metrics.batches.contentIndexCalls},
{"content_index_entries", post_ingest_metrics.batches.contentIndexEntries},
{"content_index_chunks", post_ingest_metrics.batches.contentIndexChunks},
{"content_index_max_entries", post_ingest_metrics.batches.contentIndexMaxEntries},
{"content_index_max_chunk_entries",
post_ingest_metrics.batches.contentIndexMaxChunkEntries},
{"content_index_avg_entries_per_call",
post_ingest_metrics.batches.contentIndexCalls == 0
? 0.0
: static_cast<double>(post_ingest_metrics.batches.contentIndexEntries) /
static_cast<double>(post_ingest_metrics.batches.contentIndexCalls)},
{"content_index_avg_entries_per_chunk",
post_ingest_metrics.batches.contentIndexChunks == 0
? 0.0
: static_cast<double>(post_ingest_metrics.batches.contentIndexEntries) /
static_cast<double>(post_ingest_metrics.batches.contentIndexChunks)}};
j["queues"] = {{"max_store_document_tasks", max_store_document_tasks},
{"max_embed_jobs", max_embed_jobs},
{"max_fts5_jobs", max_fts5_jobs},
{"max_post_ingest", max_post_ingest},
{"max_kg_jobs", max_kg_jobs},
{"max_symbol_jobs", max_symbol_jobs},
{"max_entity_jobs", max_entity_jobs},
{"max_title_jobs", max_title_jobs},
{"dropped_batches", dropped_batches},
{"sample_count", queue_samples.size()}};
j["enrichment_status"] = {{"kg",
{{"queued", final_kg_queued},
{"consumed", final_kg_consumed},
{"dropped", final_kg_dropped}}},
{"symbol",
{{"queued", final_symbol_queued},
{"consumed", final_symbol_consumed},
{"dropped", final_symbol_dropped}}},
{"entity",
{{"queued", final_entity_queued},
{"consumed", final_entity_consumed},
{"dropped", final_entity_dropped}}},
{"title",
{{"queued", final_title_queued},
{"consumed", final_title_consumed},
{"dropped", final_title_dropped}}}};
// Include first/last 5 samples for debugging
if (!queue_samples.empty()) {
json samples_head = json::array();
json samples_tail = json::array();
for (size_t i = 0; i < std::min<size_t>(5, queue_samples.size()); ++i) {
samples_head.push_back(queue_samples[i].toJson());
}
size_t tail_start = queue_samples.size() > 5 ? queue_samples.size() - 5 : 0;
for (size_t i = tail_start; i < queue_samples.size(); ++i) {
samples_tail.push_back(queue_samples[i].toJson());
}
j["queues"]["samples_head"] = samples_head;
j["queues"]["samples_tail"] = samples_tail;
}
j["work_coordinator"] = {{"thread_count", thread_count}};
if (has_write_coordinator_stats) {
const auto& stats = write_coordinator_stats;
j["write_coordinator"] = {
{"batches_enqueued", stats.batchesEnqueued},
{"batches_committed", stats.batchesCommitted},
{"ops_applied", stats.opsApplied},
{"commit_errors", stats.commitErrors},
{"nodes_upserted", stats.nodesUpserted},
{"edges_added", stats.edgesAdded},
{"edges_coalesced", stats.edgesCoalesced},
{"aliases_added", stats.aliasesAdded},
{"doc_entities_added", stats.docEntitiesAdded},
{"symbols_upserted", stats.symbolsUpserted},
{"node_key_lookups_batched", stats.nodeKeyLookupsBatched},
{"max_queue_depth", stats.maxQueueDepth},
{"capacity_rejections", stats.capacityRejections},
{"forced_enqueues_over_capacity", stats.forcedEnqueuesOverCapacity},
{"max_batch_apply_ms", stats.maxBatchApplyMs},
{"max_batch_queue_wait_ms", stats.maxBatchQueueWaitMs},
{"max_batch_excess_queue_wait_ms", stats.maxBatchExcessQueueWaitMs}};
json hotSources = json::array();
for (const auto& source : stats.hotSources) {
hotSources.push_back({{"source", source.source},
{"batches", source.batches},
{"ops", source.ops},
{"errors", source.errors},
{"total_queue_wait_ms", source.totalQueueWaitMs},
{"max_queue_wait_ms", source.maxQueueWaitMs},
{"max_excess_queue_wait_ms", source.maxExcessQueueWaitMs},
{"total_apply_ms", source.totalApplyMs},
{"max_apply_ms", source.maxApplyMs}});
}
j["write_coordinator"]["hot_sources"] = std::move(hotSources);
}
return j;
}
};
// ============================================================================
// Helper: Get current timestamp in milliseconds
// ============================================================================
int64_t nowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
std::string nowIso8601() {
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
std::ostringstream oss;
oss << std::put_time(std::gmtime(&time_t), "%Y-%m-%dT%H:%M:%SZ");
return oss.str();
}
// ============================================================================
// captureQueueSnapshot - Read queue depths directly from InternalEventBus
// ============================================================================
QueueSnapshot captureQueueSnapshot() {
using namespace yams::daemon;
auto& bus = InternalEventBus::instance();
QueueSnapshot snap;
snap.timestamp_ms = nowMs();
// Get actual queue depths by accessing channels
if (auto q = bus.get_or_create_channel<InternalEventBus::StoreDocumentTask>(
"store_document_tasks", 4096)) {
snap.store_document_tasks = q->size_approx();
}
if (auto q = bus.get_or_create_channel<InternalEventBus::EmbedJob>("embed_jobs", 4096)) {
snap.embed_jobs = q->size_approx();
}
if (auto q = bus.get_or_create_channel<InternalEventBus::Fts5Job>("fts5_jobs", 4096)) {
snap.fts5_jobs = q->size_approx();
}
if (auto q = bus.get_or_create_channel<InternalEventBus::PostIngestTask>("post_ingest", 4096)) {
snap.post_ingest = q->size_approx();
}
if (auto q = bus.get_channel<InternalEventBus::KgJob>("kg_jobs")) {
snap.kg_jobs = q->size_approx();
}
if (auto q = bus.get_channel<InternalEventBus::SymbolExtractionJob>("symbol_extraction")) {
snap.symbol_jobs = q->size_approx();
}
if (auto q = bus.get_channel<InternalEventBus::EntityExtractionJob>("entity_extraction")) {
snap.entity_jobs = q->size_approx();
}
if (auto q = bus.get_channel<InternalEventBus::TitleExtractionJob>("title_extraction")) {
snap.title_jobs = q->size_approx();
}
// Get cumulative counters (these work for completion detection)
snap.embed_queued = bus.embedQueued();
snap.embed_consumed = bus.embedConsumed();
snap.embed_dropped = bus.embedDropped();
snap.embed_prepared_docs_queued = bus.embedPreparedDocsQueued();
snap.embed_prepared_chunks_queued = bus.embedPreparedChunksQueued();
snap.embed_hash_only_docs_queued = bus.embedHashOnlyDocsQueued();
snap.fts5_queued = bus.fts5Queued();
snap.fts5_consumed = bus.fts5Consumed();
snap.fts5_dropped = bus.fts5Dropped();
snap.post_queued = bus.postQueued();
snap.post_consumed = bus.postConsumed();
snap.post_dropped = bus.postDropped();
snap.kg_queued = bus.kgQueued();
snap.kg_consumed = bus.kgConsumed();
snap.kg_dropped = bus.kgDropped();
snap.symbol_queued = bus.symbolQueued();
snap.symbol_consumed = bus.symbolConsumed();
snap.symbol_dropped = bus.symbolDropped();
snap.entity_queued = bus.entityQueued();
snap.entity_consumed = bus.entityConsumed();
snap.entity_dropped = bus.entityDropped();
snap.title_queued = bus.titleQueued();
snap.title_consumed = bus.titleConsumed();
snap.title_dropped = bus.titleDropped();
return snap;
}
std::vector<SearchImpactProbe>
runSearchImpactProbes(const std::shared_ptr<yams::daemon::DaemonClient>& client,
bool vectorsDisabled, bool kgEnabled, bool pipelineComplete) {
if (const char* searchProbes = std::getenv("YAMS_BENCH_SEARCH_PROBES");
searchProbes && std::string(searchProbes) == "0") {
return {};
}
std::vector<SearchImpactProbe> probes;
probes.push_back({.name = "keyword",
.query = "Synthetic Test Corpus",
.search_type = "keyword",
.required_by_contract = pipelineComplete});
probes.push_back({.name = "semantic",
.query = "synthetic document corpus",
.search_type = "semantic",
.required_by_contract = pipelineComplete && !vectorsDisabled});
probes.push_back({.name = "graph_rerank_hybrid",
.query = "synthetic document corpus",
.search_type = "hybrid",
.required_by_contract = pipelineComplete && !vectorsDisabled && kgEnabled});
for (auto& probe : probes) {
if (probe.name == "semantic" && vectorsDisabled) {
probe.skipped = true;
probe.skip_reason = "vectors_disabled";
continue;
}
if (probe.name == "graph_rerank_hybrid" && !kgEnabled) {
probe.skipped = true;
probe.skip_reason = "kg_disabled";
continue;
}
if (probe.name == "graph_rerank_hybrid" && vectorsDisabled) {
probe.skipped = true;
probe.skip_reason = "vectors_disabled";
continue;
}
if (!pipelineComplete) {
probe.skipped = true;
probe.skip_reason = "pipeline_incomplete";
continue;
}
yams::daemon::SearchRequest req;
req.query = probe.query;
req.searchType = probe.search_type;
req.limit = 10;
req.useSession = false;
req.globalSearch = true;
req.showHash = true;
req.timeout = std::chrono::milliseconds(10000);
const auto start = nowMs();
auto response = yams::cli::run_sync(client->search(req), 15s);
const auto end = nowMs();
probe.wall_ms = nonNegativeDeltaMs(end, start);
probe.completed_ms = end;
if (!response) {
probe.ok = false;
probe.error = response.error().message;
continue;
}
const auto& value = response.value();
probe.ok = true;
probe.daemon_elapsed_ms = value.elapsed.count();
probe.total_count = static_cast<uint64_t>(value.totalCount);
probe.returned_count = static_cast<uint64_t>(value.results.size());
for (const auto& item : value.results) {
if (!item.id.empty()) {
probe.top_ids.push_back(item.id);
}
if (probe.top_ids.size() >= 5) {
break;
}
}
}
return probes;
}
// ============================================================================
// runBenchmark - Main benchmark logic
// ============================================================================
BenchmarkResult runBenchmark(int corpusSize, int docSize, int pollIntervalMs,
std::string ingestMode, std::size_t ingestConcurrency) {
yams::daemon::TuneAdvisor::setPostIngestBatchSize(benchPostIngestBatchSize());
BenchmarkResult result;
result.corpus_size = corpusSize;
result.doc_size = docSize;
result.poll_interval_ms = pollIntervalMs;
result.ingest_mode = std::move(ingestMode);
result.ingest_concurrency = std::max<std::size_t>(1, ingestConcurrency);
result.post_ingest_coalesce_ms = benchPostIngestCoalesceMs();
result.post_ingest_batch_size = yams::daemon::TuneAdvisor::postIngestBatchSize();
result.timestamp = nowIso8601();
if (const char* env = std::getenv("YAMS_BENCH_EMBED_MODEL"); env && *env) {
result.embedding_model = env;
}
const char* forceMockEmbeddings = std::getenv("YAMS_BENCH_FORCE_MOCK_EMBEDDINGS");
const bool mockEmbeddingsRequested =
forceMockEmbeddings && std::string(forceMockEmbeddings) == "1";
const char* disableVectors = std::getenv("YAMS_DISABLE_VECTORS");
const bool vectorsDisabled =
mockEmbeddingsRequested || (disableVectors && std::string(disableVectors) == "1");
const bool simeonModel =
result.embedding_model == "simeon-default" || result.embedding_model == "simeon";
result.embedding_backend = vectorsDisabled ? "mock" : (simeonModel ? "simeon" : "onnxruntime");
result.simeon_recipe =
(!vectorsDisabled && simeonModel) ? yams::vector::simeonRecipeLabel() : "";
result.kg_enabled = !envFlagEnabled("YAMS_BENCH_DISABLE_KG");
spdlog::info("=== Ingestion E2E Benchmark ===");
spdlog::info("Corpus size: {}", corpusSize);
spdlog::info("Doc size: {} bytes", docSize);
spdlog::info("Poll interval: {} ms", pollIntervalMs);
spdlog::info("Ingest mode: {} (concurrency={})", result.ingest_mode, result.ingest_concurrency);
spdlog::info("Post-ingest coalesce window: {} ms", result.post_ingest_coalesce_ms);
spdlog::info("Post-ingest batch size: {}", result.post_ingest_batch_size);
spdlog::info("KG enrichment: {}", result.kg_enabled ? "enabled" : "disabled");
// Phase 1: Start daemon
spdlog::info("Phase 1: Starting daemon...");
SimpleDaemonHarness harness;
if (!harness.start()) {
spdlog::error("Failed to start daemon");
return result;
}
// Phase 2: Generate corpus
spdlog::info("Phase 2: Generating test corpus...");
fs::path corpusDir = harness.root() / "corpus";
CorpusGenerator corpus(corpusDir, docSize);
corpus.generateDocuments(corpusSize);
result.corpus_fingerprint = corpus.fingerprint();
// Phase 3: Connect to daemon
spdlog::info("Phase 3: Connecting to daemon...");
yams::daemon::ClientConfig clientConfig;
clientConfig.socketPath = harness.socketPath();
clientConfig.autoStart = false; // Daemon already started by harness
auto client = std::make_shared<yams::daemon::DaemonClient>(clientConfig);
// Actually connect to the daemon socket
auto connectResult = yams::cli::run_sync(client->connect(), std::chrono::seconds(5));
if (!connectResult) {
spdlog::error("Failed to connect to daemon: {}", connectResult.error().message);
return result; // Return empty result on connection failure
}
spdlog::info("Connected to daemon at {}", harness.socketPath().string());
// Log model provider status
auto serviceManager = harness.daemon()->getServiceManager();
if (serviceManager) {
auto modelProvider = serviceManager->getModelProvider();
if (modelProvider && modelProvider->isAvailable()) {
auto loadedModels = modelProvider->getLoadedModels();
spdlog::info("Model provider: {} model(s) loaded: {}", loadedModels.size(),
loadedModels.empty() ? "none" : std::to_string(loadedModels.size()));
} else {
spdlog::warn("Model provider not available");
}
serviceManager->resetEmbeddingPhaseTimings();
yams::app::services::resetDocumentStorePhaseTimings();
yams::api::resetContentStorePhaseTimings();
yams::metadata::resetMetadataInsertPhaseTimings();
yams::storage::resetRefCounterCommitPhaseTimings();
yams::storage::resetRefCounterWriterMetrics();
auto appContext = serviceManager->getAppContext();
if (appContext.metadataInsertWriter) {
appContext.metadataInsertWriter->resetMetrics();
}
if (auto postIngest = serviceManager->getPostIngestQueue()) {
postIngest->resetMetrics();
if (!result.kg_enabled) {
postIngest->pauseStage(yams::daemon::PostIngestQueue::Stage::KnowledgeGraph);
}
}
}
// Capture baseline counters (start from zero)
auto& bus = yams::daemon::InternalEventBus::instance();
uint64_t baselineEmbedConsumed = bus.embedConsumed();
uint64_t baselineEmbedDropped = bus.embedDropped();
uint64_t baselinePostConsumed = bus.postConsumed();
uint64_t baselinePostDropped = bus.postDropped();
uint64_t baselineKgConsumed = bus.kgConsumed();
uint64_t baselineKgDropped = bus.kgDropped();
spdlog::info("Baseline counters: embed={}, post={}, kg={}", baselineEmbedConsumed,
baselinePostConsumed, baselineKgConsumed);
// Phase 4: Ingest documents
spdlog::info("Phase 4: Ingesting {} documents...", corpusSize);
int64_t ingestStartMs = nowMs();
result.metadata_storage.name = "metadata_storage";
result.metadata_storage.start_ms = ingestStartMs;
std::atomic<int> successCount{0};
std::atomic<int> failCount{0};
const auto addSingleFile = [&](yams::daemon::DaemonClient& addClient,
const std::string& filename) {
yams::daemon::AddDocumentRequest addReq;
addReq.path = (corpusDir / filename).string();
addReq.noEmbeddings = vectorsDisabled;
auto addResult = yams::cli::run_sync(addClient.streamingAddDocument(addReq), 30s);
if (!addResult) {
spdlog::warn("Failed to ingest {}: {}", filename, addResult.error().message);
failCount.fetch_add(1, std::memory_order_relaxed);
return;
}
const int completed = successCount.fetch_add(1, std::memory_order_relaxed) + 1;
if (completed % 10 == 0 || completed == corpusSize) {
spdlog::info("Ingested {}/{} documents", completed, corpusSize);
}
};
if (result.ingest_mode == "directory") {
// Directory admission is one recursive request regardless of the caller's
// generic concurrency default. Keep the experiment identity truthful.
result.ingest_concurrency = 1;
yams::daemon::AddDocumentRequest addReq;
addReq.path = corpusDir.string();
addReq.recursive = true;
addReq.noEmbeddings = vectorsDisabled;
addReq.includePatterns = {"*.txt"};
auto addResult = yams::cli::run_sync(client->streamingAddDocument(addReq), 120s);
if (!addResult) {
spdlog::warn("Directory ingestion failed: {}", addResult.error().message);
failCount.store(corpusSize, std::memory_order_relaxed);
} else {
successCount.store(corpusSize, std::memory_order_relaxed);
spdlog::info("Directory ingestion accepted for {} documents: {}", corpusSize,
addResult.value().message);
}
} else if (result.ingest_mode == "pipelined_single_file") {
const std::size_t requestWindow =
std::min<std::size_t>(result.ingest_concurrency, corpus.createdFiles.size());
std::vector<yams::app::services::AddOptions> batch;
batch.reserve(corpus.createdFiles.size());
for (const auto& filename : corpus.createdFiles) {
yams::app::services::AddOptions options;
options.path = (corpusDir / filename).string();
options.noEmbeddings = vectorsDisabled;
batch.push_back(std::move(options));
}
yams::app::services::DocumentIngestionService ingestionService(client);
auto submitBatch =
[&]() -> boost::asio::awaitable<yams::Result<yams::app::services::BatchAddResult>> {
co_return co_await ingestionService.addBatchAsync(batch,
static_cast<int>(requestWindow));
};
auto batchResult = yams::cli::run_sync(submitBatch(), 120s);
if (!batchResult) {
spdlog::warn("Pipelined ingestion failed: {}", batchResult.error().message);
failCount.store(corpusSize, std::memory_order_relaxed);
} else {
successCount.store(static_cast<int>(batchResult.value().succeeded),
std::memory_order_relaxed);
failCount.store(static_cast<int>(batchResult.value().failed),
std::memory_order_relaxed);
}
} else {
result.ingest_mode = "single_file_serial";
result.ingest_concurrency = 1;
for (const auto& filename : corpus.createdFiles) {
addSingleFile(*client, filename);
}
}
int64_t ingestEndMs = nowMs();
result.metadata_storage.end_ms = ingestEndMs;
result.admission_ms = nonNegativeDeltaMs(ingestEndMs, ingestStartMs);
result.metadata_storage.count = static_cast<std::uint64_t>(successCount.load());
result.metadata_storage.failures = static_cast<std::uint64_t>(failCount.load());
const auto& addStats = harness.daemon()->getState().stats;
result.add_dispatch_metrics = {
.samples = addStats.addDispatchSamples.load(std::memory_order_relaxed),
.total_us = addStats.addDispatchTotalUs.load(std::memory_order_relaxed),
.max_us = addStats.addDispatchMaxUs.load(std::memory_order_relaxed),
.fingerprint_total_us = addStats.addFingerprintTotalUs.load(std::memory_order_relaxed),
.fingerprint_max_us = addStats.addFingerprintMaxUs.load(std::memory_order_relaxed),
.enqueue_total_us = addStats.addEnqueueTotalUs.load(std::memory_order_relaxed),
.enqueue_max_us = addStats.addEnqueueMaxUs.load(std::memory_order_relaxed)};
spdlog::info("Ingestion admission complete: {} succeeded, {} failed in {} ms",
successCount.load(), failCount.load(),
nonNegativeDeltaMs(ingestEndMs, ingestStartMs));
// Phase 5: Monitor pipeline completion via InternalEventBus
spdlog::info("Phase 5: Monitoring pipeline completion...");
// Capture initial state
QueueSnapshot initialSnap = captureQueueSnapshot();
spdlog::info("Initial state: embed_queued={} fts5_queued={} post_queued={}",
initialSnap.embed_queued, initialSnap.fts5_queued, initialSnap.post_queued);
// Expected counts
// Note: FTS5 indexing happens synchronously in post-ingest metadata stage (not via jobs)
// So we only track post-ingest (metadata+FTS5) and embeddings completion
uint64_t expectedPost = static_cast<std::uint64_t>(successCount.load());
uint64_t expectedEmbed = vectorsDisabled ? 0 : static_cast<std::uint64_t>(successCount.load());
uint64_t expectedKg = result.kg_enabled ? static_cast<std::uint64_t>(successCount.load()) : 0;
result.expected_post = expectedPost;
result.expected_embed = expectedEmbed;
result.expected_kg = expectedKg;
// Poll until every expected item is consumed or explicitly dropped. Drops settle the
// workload but never satisfy the lossless completion contract.
auto pipelineDeadline = std::chrono::steady_clock::now() + 60s;
bool allStagesComplete = false;
bool allStagesSettled = false;
QueueSnapshot lastSnap = initialSnap;
uint64_t lastEmbedConsumedDelta = 0;
uint64_t lastPostConsumedDelta = 0;
uint64_t lastEmbedDroppedDelta = 0;
uint64_t lastKgConsumedDelta = 0;
uint64_t lastKgDroppedDelta = 0;
while (std::chrono::steady_clock::now() < pipelineDeadline && !allStagesSettled) {
std::this_thread::sleep_for(std::chrono::milliseconds(pollIntervalMs));
QueueSnapshot snap = captureQueueSnapshot();
result.queue_samples.push_back(snap);
lastSnap = snap;
// Update max queue depths (approximated by queued - consumed)
// Use signed arithmetic to avoid underflow, then clamp to 0
auto fts5Depth =
snap.fts5_queued > snap.fts5_consumed ? snap.fts5_queued - snap.fts5_consumed : 0;
auto embedDepth =
snap.embed_queued > snap.embed_consumed ? snap.embed_queued - snap.embed_consumed : 0;
auto postDepth =
snap.post_queued > snap.post_consumed ? snap.post_queued - snap.post_consumed : 0;
result.max_fts5_jobs = std::max(result.max_fts5_jobs, fts5Depth);
result.max_embed_jobs = std::max(result.max_embed_jobs, embedDepth);
result.max_post_ingest = std::max(result.max_post_ingest, postDepth);
result.max_kg_jobs = std::max(result.max_kg_jobs, snap.kg_jobs);
result.max_symbol_jobs = std::max(result.max_symbol_jobs, snap.symbol_jobs);
result.max_entity_jobs = std::max(result.max_entity_jobs, snap.entity_jobs);
result.max_title_jobs = std::max(result.max_title_jobs, snap.title_jobs);
// Track dropped batches (excluding warmup baseline)
uint64_t embedDroppedDelta = counterDelta(snap.embed_dropped, baselineEmbedDropped);
uint64_t postDroppedDelta = counterDelta(snap.post_dropped, baselinePostDropped);
uint64_t kgDroppedDelta = counterDelta(snap.kg_dropped, baselineKgDropped);
const uint64_t countedKgDroppedDelta = result.kg_enabled ? kgDroppedDelta : 0;
result.dropped_batches = saturatedAdd(
saturatedAdd(saturatedAdd(snap.fts5_dropped, embedDroppedDelta), postDroppedDelta),
countedKgDroppedDelta);
const uint64_t embedConsumedDelta =
counterDelta(snap.embed_consumed, baselineEmbedConsumed);
const uint64_t postConsumedDelta = counterDelta(snap.post_consumed, baselinePostConsumed);
const uint64_t kgConsumedDelta =
result.kg_enabled ? counterDelta(snap.kg_consumed, baselineKgConsumed) : 0;
lastEmbedConsumedDelta = embedConsumedDelta;
lastPostConsumedDelta = postConsumedDelta;
lastKgConsumedDelta = kgConsumedDelta;
lastEmbedDroppedDelta = embedDroppedDelta;
lastKgDroppedDelta = kgDroppedDelta;
// Completion is exact: consuming more work than was admitted indicates a replay or
// duplicate enqueue. Keep the settled checks below permissive so the benchmark can stop
// and report the over-count instead of waiting for a timeout.
const bool embedComplete = embedConsumedDelta == expectedEmbed;
const bool postComplete = postConsumedDelta == expectedPost;
const bool kgComplete = kgConsumedDelta == expectedKg;
const bool embedSettled =
saturatedAdd(embedConsumedDelta, embedDroppedDelta) >= expectedEmbed;
const bool postSettled = saturatedAdd(postConsumedDelta, postDroppedDelta) >= expectedPost;
const bool kgSettled =
saturatedAdd(kgConsumedDelta, result.kg_enabled ? kgDroppedDelta : 0) >= expectedKg;
allStagesComplete =
embedComplete && postComplete && kgComplete && result.dropped_batches == 0;
allStagesSettled = embedSettled && postSettled && kgSettled;
if (nonNegativeDeltaMs(snap.timestamp_ms, initialSnap.timestamp_ms) % 1000 <
pollIntervalMs) {
spdlog::info("Pipeline status: post={}/{} (dropped={}) embed={}/{} (dropped={}) "
"kg={}/{} (dropped={})",
postConsumedDelta, expectedPost, postDroppedDelta, embedConsumedDelta,
expectedEmbed, embedDroppedDelta, kgConsumedDelta, expectedKg,
kgDroppedDelta);
}
if (allStagesSettled) {
// Update stage metrics (use deltas to exclude warmup)
int64_t now = nowMs();
result.fts5_extraction.name = "fts5_extraction";
result.fts5_extraction.start_ms = ingestStartMs;
result.fts5_extraction.end_ms = now;
result.fts5_extraction.count =
snap.fts5_consumed; // FTS5 counter not affected by warmup
result.fts5_extraction.failures = snap.fts5_dropped;
result.embedding_generation.name = "embedding_generation";
result.embedding_generation.start_ms = ingestStartMs;
result.embedding_generation.end_ms = now;
result.embedding_generation.count =
counterDelta(snap.embed_consumed, baselineEmbedConsumed);
result.embedding_generation.failures = embedDroppedDelta;
result.kg_extraction.name = "kg_extraction";
result.kg_extraction.start_ms = ingestStartMs;
result.kg_extraction.end_ms = now;
result.kg_extraction.count = counterDelta(snap.kg_consumed, baselineKgConsumed);
result.kg_extraction.failures = kgDroppedDelta;
spdlog::info("All pipeline stages settled (lossless={})", allStagesComplete);
break;
}
}
result.stages_settled = allStagesSettled;
const int64_t storageReadyEndMs = nowMs();
result.storage_ready_ms = nonNegativeDeltaMs(storageReadyEndMs, ingestStartMs);
result.observed_post = lastPostConsumedDelta;
result.observed_embed = lastEmbedConsumedDelta;
result.observed_kg = lastKgConsumedDelta;
if (!allStagesSettled) {
spdlog::warn("Pipeline settlement timeout after 60s (post={}/{} embed={}/{} kg={}/{})",
lastPostConsumedDelta, expectedPost, lastEmbedConsumedDelta, expectedEmbed,
lastKgConsumedDelta, expectedKg);
// Preserve partial pipeline evidence instead of leaving stage metrics at zero. A timeout is
// still a failure, but partial counters identify which PostIngestQueue branch stalled.
const int64_t now = nowMs();
result.fts5_extraction.name = "fts5_extraction";
result.fts5_extraction.start_ms = ingestStartMs;
result.fts5_extraction.end_ms = now;
result.fts5_extraction.count = lastSnap.fts5_consumed;
result.fts5_extraction.failures = lastSnap.fts5_dropped;
result.embedding_generation.name = "embedding_generation";
result.embedding_generation.start_ms = ingestStartMs;
result.embedding_generation.end_ms = now;
result.embedding_generation.count =
counterDelta(lastSnap.embed_consumed, baselineEmbedConsumed);
result.embedding_generation.failures = lastEmbedDroppedDelta;
result.kg_extraction.name = "kg_extraction";
result.kg_extraction.start_ms = ingestStartMs;
result.kg_extraction.end_ms = now;
result.kg_extraction.count = counterDelta(lastSnap.kg_consumed, baselineKgConsumed);
result.kg_extraction.failures = lastKgDroppedDelta;
}
// The core counters can settle before entity/title work and deferred KG writes commit.
// Wait for every PostIngestQueue stage to drain, then flush the WriteCoordinator.
result.enrichment_drained = false;
const auto enrichmentDeadline = std::chrono::steady_clock::now() + 30s;
while (std::chrono::steady_clock::now() < enrichmentDeadline) {
lastSnap = captureQueueSnapshot();
bool postIngestIdle = true;
if (serviceManager) {
if (auto postIngest = serviceManager->getPostIngestQueue()) {
postIngestIdle =
postIngest->size() == 0 && postIngest->kgQueueDepth() == 0 &&
postIngest->symbolQueueDepth() == 0 && postIngest->entityQueueDepth() == 0 &&
postIngest->titleQueueDepth() == 0 && postIngest->totalInFlight() == 0;
}
}
const bool embeddingIdle =
lastSnap.embed_jobs == 0 &&
(!serviceManager || serviceManager->getEmbeddingInFlightJobs() == 0);
if (postIngestIdle && embeddingIdle) {
result.enrichment_drained = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(pollIntervalMs));
}
result.write_coordinator_flushed = true;
if (serviceManager) {
if (auto* writeCoordinator = serviceManager->getWriteCoordinator()) {
auto flushResult = writeCoordinator->flush(30s);
result.write_coordinator_flushed = static_cast<bool>(flushResult);
result.write_coordinator_stats = writeCoordinator->getStats();
result.has_write_coordinator_stats = true;
if (!flushResult) {
spdlog::warn("WriteCoordinator flush failed: {}", flushResult.error().message);
}
}
}
lastSnap = captureQueueSnapshot();
result.final_kg_queued = lastSnap.kg_queued;
result.final_kg_consumed = lastSnap.kg_consumed;
result.final_kg_dropped = lastSnap.kg_dropped;
result.final_symbol_queued = lastSnap.symbol_queued;
result.final_symbol_consumed = lastSnap.symbol_consumed;
result.final_symbol_dropped = lastSnap.symbol_dropped;
result.final_entity_queued = lastSnap.entity_queued;
result.final_entity_consumed = lastSnap.entity_consumed;
result.final_entity_dropped = lastSnap.entity_dropped;
result.final_title_queued = lastSnap.title_queued;
result.final_title_consumed = lastSnap.title_consumed;
result.final_title_dropped = lastSnap.title_dropped;
result.dropped_batches = saturatedAdd(
result.dropped_batches,
saturatedAdd(result.final_symbol_dropped,
saturatedAdd(result.final_entity_dropped, result.final_title_dropped)));
result.pipeline_complete = allStagesComplete && result.enrichment_drained &&
result.write_coordinator_flushed && result.dropped_batches == 0 &&
failCount.load(std::memory_order_relaxed) == 0;
const int64_t enrichmentEndMs = nowMs();
result.pipeline_drain_ms = nonNegativeDeltaMs(enrichmentEndMs, storageReadyEndMs);
result.enrichment_ready_ms = nonNegativeDeltaMs(enrichmentEndMs, ingestStartMs);
result.total_duration_ms = result.enrichment_ready_ms;
result.throughput_docs_per_sec =
result.total_duration_ms > 0
? static_cast<double>(successCount.load()) / (result.total_duration_ms / 1000.0)
: 0.0;
spdlog::info("Phase 6: Running fixed search-impact probes...");
result.search_impact =
runSearchImpactProbes(client, vectorsDisabled, result.kg_enabled, result.pipeline_complete);
for (const auto& probe : result.search_impact) {
if (probe.name == "keyword" && probe.ok &&
probe.total_count >= static_cast<std::uint64_t>(successCount.load())) {
result.searchability_ready_ms = nonNegativeDeltaMs(probe.completed_ms, ingestStartMs);
break;
}
}
if (serviceManager) {
result.embedding_phase_timings = serviceManager->getEmbeddingPhaseTimingsSnapshot();
result.document_store_phase_timings =
yams::app::services::getDocumentStorePhaseTimingsSnapshot();
result.content_store_phase_timings = yams::api::getContentStorePhaseTimingsSnapshot();
result.metadata_insert_phase_timings =
yams::metadata::getMetadataInsertPhaseTimingsSnapshot();
result.ref_counter_commit_phase_timings =
yams::storage::getRefCounterCommitPhaseTimingsSnapshot();
result.ref_counter_writer_timings = yams::storage::getRefCounterWriterTimingsSnapshot();
result.ref_counter_writer_value_metrics =
yams::storage::getRefCounterWriterValueMetricsSnapshot();
auto appContext = serviceManager->getAppContext();
if (appContext.metadataInsertWriter) {
result.metadata_insert_writer_metrics =
appContext.metadataInsertWriter->metricsSnapshot();
}
if (auto postIngest = serviceManager->getPostIngestQueue()) {
result.post_ingest_metrics = postIngest->metricsSnapshot();
}
}
spdlog::info("=== Benchmark Complete ===");
spdlog::info("Total duration: {} ms", result.total_duration_ms);
spdlog::info("Throughput: {:.2f} docs/sec", result.throughput_docs_per_sec);
spdlog::info("Dropped batches: {}", result.dropped_batches);
return result;
}
// ============================================================================
// main - Entry point
// ============================================================================
int main(int /*argc*/, char* /*argv*/[]) {
// Set log level
const char* logLevel = std::getenv("YAMS_LOG_LEVEL");
if (logLevel && std::string(logLevel) == "debug") {
spdlog::set_level(spdlog::level::debug);
} else {
spdlog::set_level(spdlog::level::info);
}
// Read configuration from environment
int corpusSize = 100;
if (const char* env = std::getenv("YAMS_BENCH_CORPUS_SIZE")) {
corpusSize = std::atoi(env);
}
int docSize = 1000;
if (const char* env = std::getenv("YAMS_BENCH_DOC_SIZE")) {
docSize = std::atoi(env);
}
int pollInterval = 100;
if (const char* env = std::getenv("YAMS_BENCH_POLL_INTERVAL_MS")) {
pollInterval = std::atoi(env);
}
std::string ingestMode = "single_file_serial";
if (const char* env = std::getenv("YAMS_BENCH_INGEST_MODE")) {
ingestMode = env;
}
std::size_t ingestConcurrency = 4;
if (const char* env = std::getenv("YAMS_BENCH_INGEST_CONCURRENCY")) {
ingestConcurrency = static_cast<std::size_t>(std::max(1, std::atoi(env)));
}
const char* outputPath = std::getenv("YAMS_BENCH_OUTPUT");
// Run benchmark
BenchmarkResult result =
runBenchmark(corpusSize, docSize, pollInterval, ingestMode, ingestConcurrency);
// Output results
json output = result.toJson();
std::string jsonStr = output.dump(2);
if (outputPath) {
std::ofstream outFile(outputPath);
outFile << jsonStr << std::endl;
spdlog::info("Results written to: {}", outputPath);
} else {
std::cout << jsonStr << std::endl;
}
return result.metadata_storage.failures > 0 || !result.pipeline_complete ||
result.dropped_batches > 0
? 1
: 0;
}