Some checks failed
Fuzz Smoke / libFuzzer smoke (push) Has been cancelled
* chore(warnings): default-initialize aggregate members left out of brace-init
GCC -Wmissing-field-initializers fired about 1300 times where tests and
src build structs with a short positional brace list. Give the omitted
members an empty default member initializer. Value-initialization from
{} is what aggregate init already did for them, so behavior is
unchanged.
Signed-off-by: trvon <me@trevon.dev>
* chore(warnings): remove or annotate unused parameters, functions and variables
- init_command, s3_signer test, request_handler_lifetime test: comment out
the unused parameter name.
- video_content_handler: path is only read under YAMS_HAVE_FFPROBE.
- vector_memory_scaling_bench: main() takes no arguments.
- daemon_status_render: delete unused neutralText helper.
- DaemonMetrics: mallocStackLoggingNoCompactEnabled is only called in the
__APPLE__ block, so define it there.
- SocketServer: einval_streak only matters in the __APPLE__ EINVAL path.
- onnx_plugin_harness test, vector_backend_engine_compare bench,
work_coordinator test: mark platform-conditional or busy-loop
variables [[maybe_unused]].
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): fix sign-compare, range-loop, nodiscard and dangling-ref in tests
- search_admission_controller: count started workers in std::uint32_t so
the comparison with the uint32 cap is same-signed.
- p2p_manager: iterate the invalid-host list by const reference.
- integrity_verifier: discard the nodiscard results the tests do not
inspect with static_cast<void>.
- post_ingest_queue: findNode/findEdge take std::string_view so no
temporary std::string is created for the call (GCC
-Wdangling-reference false positive on the returned reference).
- topology_ablation_bench: the doc comment ended a line with a backslash,
which continues the comment (-Wcomment); show the env export on its own
line.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): drop redundant std::move of a const captured json
The coroutine lambda is not mutable, so the captured optional<json> is
const and std::move(*progressToken) already selected the copy assignment.
Drop the std::move so the code states what actually happens.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): mark __int128 use in db_integrity_stamp as an extension
-Wpedantic flags __int128. Use a __extension__ typedef, the GCC/Clang
idiom for an intentional extension, instead of the bare keyword.
Signed-off-by: trvon <git@trevon.dev>
* chore(warnings): remove always-true unsigned >= 0 comparisons
GCC -Wtype-limits flagged 51 test assertions of the form
CHECK(unsignedValue >= 0) that can never fail, plus a dead
`totalCount.value() < 0` clause in the graph listByType handler (the
count is std::size_t). In the tests keep the evaluation as a
static_cast<void> so the accessors are still exercised; in the handler
drop the dead clause and keep the range check.
Signed-off-by: trvon <me@trevon.dev>
---------
Signed-off-by: trvon <me@trevon.dev>
Signed-off-by: trvon <git@trevon.dev>
8025 lines
359 KiB
C++
8025 lines
359 KiB
C++
// Multi-client ingestion and concurrent access benchmark
|
|
// Benchmarks the daemon's ability to handle:
|
|
// 1. Concurrent ingestion from multiple clients
|
|
// 2. Mixed read/write workloads (ingest + search + list)
|
|
// 3. Connection fairness and latency under contention
|
|
// 4. Per-client throughput scaling vs total throughput
|
|
// 5. Backpressure and queue depth behavior under multi-client load
|
|
//
|
|
// Designed for recursive tuning: run repeatedly, adjusting TuneAdvisor knobs,
|
|
// connection pool sizes, and I/O thread counts to optimize multi-client performance.
|
|
//
|
|
// Environment variables:
|
|
// YAMS_BENCH_NUM_CLIENTS - Number of concurrent clients (default: 4)
|
|
// YAMS_BENCH_DOCS_PER_CLIENT - Documents each client ingests (default: 100)
|
|
// YAMS_BENCH_DOC_SIZE_BYTES - Average document size in bytes (default: 2048)
|
|
// YAMS_BENCH_SEARCH_RATIO - Fraction of ops that are search (default: 0.2)
|
|
// YAMS_BENCH_MIXED_OPS_PER_CLIENT - Fixed operation budget per mixed client (default: derived)
|
|
// YAMS_BENCH_WARMUP_DOCS - Docs to pre-ingest for search queries (default: 50)
|
|
// YAMS_BENCH_OUTPUT - JSONL output path (default: bench_results/multi_client.jsonl)
|
|
// YAMS_BENCH_DRAIN_TIMEOUT_S - Seconds to wait for queue drain (default: 120)
|
|
// YAMS_BENCH_SCALING_MAX_CLIENTS - Max clients in scaling sweep (default: 32)
|
|
// YAMS_TUNING_PROFILE - TuneAdvisor profile (Quiet/Balanced/Performance)
|
|
// YAMS_BENCH_DATA_DIR - Existing YAMS data dir (for large-corpus bench)
|
|
// YAMS_BENCH_ENABLE_PLUGINS - Enable plugin loading: 1/true/yes (default: off)
|
|
// YAMS_BENCH_PLUGIN_DIR - Directory containing plugin .dylib/.so files
|
|
// YAMS_BENCH_TRUSTED_PLUGIN_PATHS - Colon-separated trusted plugin search paths
|
|
// YAMS_BENCH_REAL_MODEL_PROVIDER - Use real ONNX model provider: 1/true/yes
|
|
// YAMS_BENCH_SEARCH_QUERY - Daemon search query for mixed-load phases
|
|
// YAMS_BENCH_SEARCH_TYPE - Daemon search type: keyword|semantic|hybrid
|
|
// YAMS_BENCH_SEARCH_LIMIT - Daemon search result limit
|
|
// YAMS_BENCH_SEARCH_PATHS_ONLY - Skip result hydration and return paths only
|
|
// YAMS_BENCH_SNIPPET_HYDRATION_TIMEOUT_MS - Search snippet hydration budget
|
|
// YAMS_BENCH_PROFILE_HYDRATION_SURFACES - Add gated grep/graph render probes
|
|
// YAMS_BENCH_CLI_SEARCH_QUERY - CLI search query for full subprocess probes
|
|
// YAMS_BENCH_CLI_SEARCH_TYPE - CLI search type: keyword|semantic|hybrid
|
|
// YAMS_BENCH_CLI_SEARCH_LIMIT - CLI search result limit
|
|
// YAMS_BENCH_MCP_SESSION_CHURN_EVERY_N - MCP only: every Nth request uses a fresh
|
|
// MCP session (default: 0; profile may override)
|
|
// YAMS_BENCH_IDLE_PROBE - Enable post-run idle transition probe (default: on)
|
|
// YAMS_BENCH_IDLE_PROBE_BASELINE_MS - Idle baseline sampling window (default: 1500)
|
|
// YAMS_BENCH_IDLE_PROBE_SAMPLE_MS - Idle probe sampling interval (default: 50)
|
|
// YAMS_BENCH_IDLE_PROBE_DETECT_TIMEOUT_MS - Max wake detection window (default: 5000)
|
|
// YAMS_BENCH_MIXED_WRITERS - Mixed-ops writer client count (default: 4)
|
|
// YAMS_BENCH_MIXED_SEARCHERS - Mixed-ops search client count (default: 4)
|
|
// YAMS_BENCH_MIXED_LISTERS - Mixed-ops list client count (default: 4)
|
|
// YAMS_BENCH_MIXED_STATUS - Mixed-ops status/get client count (default: 4)
|
|
// YAMS_BENCH_MIXED_READER_OPS - Mixed-ops ops per reader client (default: 200)
|
|
// YAMS_BENCH_MIXED_MAX_FAIL_RATE - Mixed-ops maximum tolerated fail rate (default: 0.0)
|
|
|
|
#define CATCH_CONFIG_MAIN
|
|
#include <catch2/catch_session.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <numeric>
|
|
#include <optional>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#include <process.h>
|
|
#define getpid _getpid
|
|
#else
|
|
#include <dirent.h>
|
|
#include <fcntl.h>
|
|
#include <limits.h>
|
|
#include <spawn.h>
|
|
#include <unistd.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
|
|
extern char** environ;
|
|
#endif
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <spdlog/spdlog.h>
|
|
#include "../common/test_helpers_catch2.h"
|
|
#include "../integration/daemon/test_async_helpers.h"
|
|
#include "../integration/daemon/test_daemon_harness.h"
|
|
#include "bench_utils.h"
|
|
#include <boost/asio/connect.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/local/stream_protocol.hpp>
|
|
#include <yams/daemon/client/asio_connection_pool.h>
|
|
#include <yams/daemon/client/daemon_client.h>
|
|
#include <yams/daemon/components/ServiceManager.h>
|
|
#include <yams/daemon/components/TuneAdvisor.h>
|
|
#include <yams/daemon/metric_keys.h>
|
|
#include <yams/mcp/mcp_server.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
|
|
namespace fs = std::filesystem;
|
|
using json = nlohmann::json;
|
|
using namespace std::chrono_literals;
|
|
using namespace yams::daemon;
|
|
using namespace yams::test;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Configuration
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
namespace {
|
|
|
|
constexpr int kMaxBenchClients = 512;
|
|
constexpr int kMaxBenchDocsPerClient = 1'000'000;
|
|
constexpr int kMaxBenchWarmupDocs = 1'000'000;
|
|
constexpr int kMaxBenchSeconds = 24 * 60 * 60;
|
|
constexpr int kMaxBenchMilliseconds = 60 * 60 * 1000;
|
|
constexpr int kMaxBenchStressRounds = 1000;
|
|
constexpr int kMaxBenchOpsPerClient = 1'000'000;
|
|
constexpr int kMaxBenchPageSize = 1'000'000;
|
|
constexpr int kMaxBenchHashLimit = 10'000'000;
|
|
constexpr std::size_t kMaxBenchDocSizeBytes = 1024ULL * 1024ULL * 1024ULL;
|
|
constexpr std::size_t kMaxBenchSearchLimit = 1'000'000;
|
|
constexpr std::int64_t kMaxBenchmarkLatencyUs = 30LL * 24 * 60 * 60 * 1'000'000;
|
|
constexpr std::int64_t kMaxBenchmarkWallMs = 30LL * 24 * 60 * 60 * 1000;
|
|
|
|
int clampInt64ToInt(std::int64_t value, int minValue, int maxValue) noexcept {
|
|
if (value < static_cast<std::int64_t>(minValue)) {
|
|
return minValue;
|
|
}
|
|
if (value > static_cast<std::int64_t>(maxValue)) {
|
|
return maxValue;
|
|
}
|
|
return static_cast<int>(value);
|
|
}
|
|
|
|
int parseEnvIntClamped(const char* name, int defaultValue, int minValue, int maxValue) noexcept {
|
|
const char* raw = std::getenv(name);
|
|
if (raw == nullptr || *raw == '\0') {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
char* end = nullptr;
|
|
errno = 0;
|
|
const long long parsed = std::strtoll(raw, &end, 10);
|
|
if (end == raw || (end != nullptr && *end != '\0')) {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
if (errno == ERANGE) {
|
|
return parsed < 0 ? minValue : maxValue;
|
|
}
|
|
return clampInt64ToInt(static_cast<std::int64_t>(parsed), minValue, maxValue);
|
|
}
|
|
|
|
bool parseEnvBool(const char* name, bool defaultValue = false) {
|
|
const char* raw = std::getenv(name);
|
|
if (raw == nullptr) {
|
|
return defaultValue;
|
|
}
|
|
const std::string value(raw);
|
|
return value == "1" || value == "true" || value == "yes" || value == "on";
|
|
}
|
|
|
|
std::size_t parseEnvSizeClamped(const char* name, std::size_t defaultValue, std::size_t minValue,
|
|
std::size_t maxValue) noexcept {
|
|
const char* raw = std::getenv(name);
|
|
if (raw == nullptr || *raw == '\0' || *raw == '-') {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
char* end = nullptr;
|
|
errno = 0;
|
|
const unsigned long long parsed = std::strtoull(raw, &end, 10);
|
|
if (end == raw || (end != nullptr && *end != '\0')) {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
if (errno == ERANGE) {
|
|
return maxValue;
|
|
}
|
|
return std::clamp(static_cast<std::size_t>(parsed), minValue, maxValue);
|
|
}
|
|
|
|
double parseEnvDoubleClamped(const char* name, double defaultValue, double minValue,
|
|
double maxValue) noexcept {
|
|
const char* raw = std::getenv(name);
|
|
if (raw == nullptr || *raw == '\0') {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
char* end = nullptr;
|
|
errno = 0;
|
|
const double parsed = std::strtod(raw, &end);
|
|
if (end == raw || (end != nullptr && *end != '\0') || errno == ERANGE ||
|
|
!std::isfinite(parsed)) {
|
|
return std::clamp(defaultValue, minValue, maxValue);
|
|
}
|
|
return std::clamp(parsed, minValue, maxValue);
|
|
}
|
|
|
|
int checkedMulToInt(int lhs, int rhs, int maxValue = std::numeric_limits<int>::max()) noexcept {
|
|
const auto product = static_cast<std::int64_t>(lhs) * static_cast<std::int64_t>(rhs);
|
|
return clampInt64ToInt(product, 0, maxValue);
|
|
}
|
|
|
|
int checkedAddToInt(std::initializer_list<int> values,
|
|
int maxValue = std::numeric_limits<int>::max()) noexcept {
|
|
std::int64_t total = 0;
|
|
for (int value : values) {
|
|
total += static_cast<std::int64_t>(value);
|
|
if (total > static_cast<std::int64_t>(maxValue)) {
|
|
return maxValue;
|
|
}
|
|
}
|
|
return clampInt64ToInt(total, 0, maxValue);
|
|
}
|
|
|
|
std::int64_t clampMetricSample(std::int64_t value,
|
|
std::int64_t maxValue = kMaxBenchmarkLatencyUs) noexcept {
|
|
if (value <= 0) {
|
|
return 0;
|
|
}
|
|
return std::min(value, maxValue);
|
|
}
|
|
|
|
std::int64_t clampUnsignedMetricSample(std::uint64_t value,
|
|
std::int64_t maxValue = kMaxBenchmarkLatencyUs) noexcept {
|
|
const auto unsignedMax = static_cast<std::uint64_t>(maxValue);
|
|
return value > unsignedMax ? maxValue : static_cast<std::int64_t>(value);
|
|
}
|
|
|
|
void pushMetricSample(std::vector<std::int64_t>& values, std::int64_t value,
|
|
std::int64_t maxValue = kMaxBenchmarkLatencyUs) {
|
|
if (value >= 0) {
|
|
values.push_back(clampMetricSample(value, maxValue));
|
|
}
|
|
}
|
|
|
|
std::int64_t checkedMetricAdd(std::int64_t lhs, std::int64_t rhs,
|
|
std::int64_t maxValue = std::numeric_limits<std::int64_t>::max()) {
|
|
lhs = clampMetricSample(lhs, maxValue);
|
|
rhs = clampMetricSample(rhs, maxValue);
|
|
if (lhs > maxValue - rhs) {
|
|
return maxValue;
|
|
}
|
|
return lhs + rhs;
|
|
}
|
|
|
|
std::int64_t checkedMetricMul(std::int64_t lhs, std::int64_t rhs,
|
|
std::int64_t maxValue = std::numeric_limits<std::int64_t>::max()) {
|
|
lhs = clampMetricSample(lhs, maxValue);
|
|
rhs = clampMetricSample(rhs, maxValue);
|
|
if (lhs != 0 && rhs > maxValue / lhs) {
|
|
return maxValue;
|
|
}
|
|
return lhs * rhs;
|
|
}
|
|
|
|
struct BenchConfig {
|
|
int numClients{4};
|
|
int docsPerClient{100};
|
|
size_t docSizeBytes{2048};
|
|
double searchRatio{0.2};
|
|
int warmupDocs{50};
|
|
fs::path outputPath{"bench_results/multi_client.jsonl"};
|
|
int drainTimeoutSecs{120};
|
|
int scalingMaxClients{32};
|
|
int stressRounds{1};
|
|
int cliProbeOpsPerCommand{3};
|
|
int cliProbeTimeoutSecs{10};
|
|
std::string tuningProfile;
|
|
std::optional<fs::path> cliBinary;
|
|
std::string searchQuery{"architecture performance tuning daemon"};
|
|
std::string searchType{"hybrid"};
|
|
size_t searchLimit{5};
|
|
bool searchPathsOnly{false};
|
|
int snippetHydrationTimeoutMs{5000};
|
|
bool profileHydrationSurfaces{false};
|
|
int resourceSampleMs{500};
|
|
std::string cliSearchQuery{"architecture performance tuning daemon"};
|
|
std::string cliSearchType{"hybrid"};
|
|
size_t cliSearchLimit{5};
|
|
bool idleProbeEnabled{true};
|
|
int idleProbeBaselineMs{1500};
|
|
int idleProbeSampleMs{50};
|
|
int idleProbeDetectTimeoutMs{5000};
|
|
int mixedWriterClients{4};
|
|
int mixedSearchClients{4};
|
|
int mixedListClients{4};
|
|
int mixedStatusClients{4};
|
|
int mixedOpsPerReader{200};
|
|
int mixedOpsPerClient{0};
|
|
double mixedMaxFailRate{0.0};
|
|
|
|
// --- Storage / corpus overrides ---
|
|
// Point at an existing YAMS data directory (e.g. /Volumes/picaso/yams)
|
|
// When set, the daemon serves this corpus instead of creating an empty temp db.
|
|
std::optional<fs::path> dataDir;
|
|
// Optional explicit config path used for daemon startup/config lookup.
|
|
std::optional<fs::path> configPath;
|
|
|
|
// --- Plugin / embedding config ---
|
|
// Enable real plugin loading (ONNX embeddings, PDF extraction, etc.)
|
|
bool enablePlugins{false};
|
|
// Directory containing built plugin .dylib/.so files
|
|
std::optional<fs::path> pluginDir;
|
|
// Trusted plugin search paths (from plugins.trust)
|
|
std::vector<fs::path> trustedPluginPaths;
|
|
// Use real model provider instead of mock (requires ONNX runtime + models)
|
|
bool useRealModelProvider{false};
|
|
bool useMcpPath{false};
|
|
std::string usageProfile{"mixed"};
|
|
std::string backend{"unknown"};
|
|
std::string authMode{""};
|
|
|
|
static BenchConfig fromEnv() {
|
|
BenchConfig cfg;
|
|
cfg.numClients =
|
|
parseEnvIntClamped("YAMS_BENCH_NUM_CLIENTS", cfg.numClients, 1, kMaxBenchClients);
|
|
cfg.docsPerClient = parseEnvIntClamped("YAMS_BENCH_DOCS_PER_CLIENT", cfg.docsPerClient, 1,
|
|
kMaxBenchDocsPerClient);
|
|
cfg.docSizeBytes = parseEnvSizeClamped("YAMS_BENCH_DOC_SIZE_BYTES", cfg.docSizeBytes, 64,
|
|
kMaxBenchDocSizeBytes);
|
|
cfg.searchRatio =
|
|
parseEnvDoubleClamped("YAMS_BENCH_SEARCH_RATIO", cfg.searchRatio, 0.0, 1.0);
|
|
cfg.warmupDocs =
|
|
parseEnvIntClamped("YAMS_BENCH_WARMUP_DOCS", cfg.warmupDocs, 0, kMaxBenchWarmupDocs);
|
|
if (auto* v = std::getenv("YAMS_BENCH_OUTPUT"))
|
|
cfg.outputPath = v;
|
|
cfg.drainTimeoutSecs = parseEnvIntClamped("YAMS_BENCH_DRAIN_TIMEOUT_S",
|
|
cfg.drainTimeoutSecs, 10, kMaxBenchSeconds);
|
|
cfg.scalingMaxClients = parseEnvIntClamped("YAMS_BENCH_SCALING_MAX_CLIENTS",
|
|
cfg.scalingMaxClients, 1, kMaxBenchClients);
|
|
cfg.stressRounds = parseEnvIntClamped("YAMS_BENCH_STRESS_ROUNDS", cfg.stressRounds, 1,
|
|
kMaxBenchStressRounds);
|
|
cfg.cliProbeOpsPerCommand = parseEnvIntClamped(
|
|
"YAMS_BENCH_CLI_PROBE_OPS", cfg.cliProbeOpsPerCommand, 0, kMaxBenchOpsPerClient);
|
|
cfg.cliProbeTimeoutSecs = parseEnvIntClamped("YAMS_BENCH_CLI_PROBE_TIMEOUT_S",
|
|
cfg.cliProbeTimeoutSecs, 1, kMaxBenchSeconds);
|
|
if (auto* v = std::getenv("YAMS_BENCH_CLI_BIN"))
|
|
cfg.cliBinary = fs::path(v);
|
|
if (auto* v = std::getenv("YAMS_BENCH_SEARCH_QUERY"))
|
|
cfg.searchQuery = v;
|
|
if (auto* v = std::getenv("YAMS_BENCH_SEARCH_TYPE"))
|
|
cfg.searchType = v;
|
|
cfg.searchLimit = parseEnvSizeClamped("YAMS_BENCH_SEARCH_LIMIT", cfg.searchLimit, 1,
|
|
kMaxBenchSearchLimit);
|
|
cfg.searchPathsOnly = parseEnvBool("YAMS_BENCH_SEARCH_PATHS_ONLY");
|
|
cfg.snippetHydrationTimeoutMs =
|
|
parseEnvIntClamped("YAMS_BENCH_SNIPPET_HYDRATION_TIMEOUT_MS",
|
|
cfg.snippetHydrationTimeoutMs, 0, kMaxBenchMilliseconds);
|
|
cfg.profileHydrationSurfaces = parseEnvBool("YAMS_BENCH_PROFILE_HYDRATION_SURFACES");
|
|
cfg.resourceSampleMs = parseEnvIntClamped("YAMS_BENCH_RESOURCE_SAMPLE_MS",
|
|
cfg.resourceSampleMs, 10, kMaxBenchMilliseconds);
|
|
if (auto* v = std::getenv("YAMS_BENCH_CLI_SEARCH_QUERY"))
|
|
cfg.cliSearchQuery = v;
|
|
if (auto* v = std::getenv("YAMS_BENCH_CLI_SEARCH_TYPE"))
|
|
cfg.cliSearchType = v;
|
|
cfg.cliSearchLimit = parseEnvSizeClamped("YAMS_BENCH_CLI_SEARCH_LIMIT", cfg.cliSearchLimit,
|
|
1, kMaxBenchSearchLimit);
|
|
if (auto* v = std::getenv("YAMS_BENCH_IDLE_PROBE")) {
|
|
std::string s(v);
|
|
cfg.idleProbeEnabled = !(s == "0" || s == "false" || s == "no" || s == "off");
|
|
}
|
|
cfg.idleProbeBaselineMs = parseEnvIntClamped(
|
|
"YAMS_BENCH_IDLE_PROBE_BASELINE_MS", cfg.idleProbeBaselineMs, 0, kMaxBenchMilliseconds);
|
|
cfg.idleProbeSampleMs = parseEnvIntClamped(
|
|
"YAMS_BENCH_IDLE_PROBE_SAMPLE_MS", cfg.idleProbeSampleMs, 10, kMaxBenchMilliseconds);
|
|
cfg.idleProbeDetectTimeoutMs =
|
|
parseEnvIntClamped("YAMS_BENCH_IDLE_PROBE_DETECT_TIMEOUT_MS",
|
|
cfg.idleProbeDetectTimeoutMs, 250, kMaxBenchMilliseconds);
|
|
cfg.mixedWriterClients = parseEnvIntClamped("YAMS_BENCH_MIXED_WRITERS",
|
|
cfg.mixedWriterClients, 1, kMaxBenchClients);
|
|
cfg.mixedSearchClients = parseEnvIntClamped("YAMS_BENCH_MIXED_SEARCHERS",
|
|
cfg.mixedSearchClients, 1, kMaxBenchClients);
|
|
cfg.mixedListClients = parseEnvIntClamped("YAMS_BENCH_MIXED_LISTERS", cfg.mixedListClients,
|
|
1, kMaxBenchClients);
|
|
cfg.mixedStatusClients = parseEnvIntClamped("YAMS_BENCH_MIXED_STATUS",
|
|
cfg.mixedStatusClients, 1, kMaxBenchClients);
|
|
cfg.mixedOpsPerReader = parseEnvIntClamped("YAMS_BENCH_MIXED_READER_OPS",
|
|
cfg.mixedOpsPerReader, 1, kMaxBenchOpsPerClient);
|
|
cfg.mixedOpsPerClient = parseEnvIntClamped("YAMS_BENCH_MIXED_OPS_PER_CLIENT",
|
|
cfg.mixedOpsPerClient, 0, kMaxBenchOpsPerClient);
|
|
cfg.mixedMaxFailRate =
|
|
parseEnvDoubleClamped("YAMS_BENCH_MIXED_MAX_FAIL_RATE", cfg.mixedMaxFailRate, 0.0, 1.0);
|
|
cfg.scalingMaxClients =
|
|
std::max(cfg.scalingMaxClients, checkedMulToInt(cfg.numClients, 2, kMaxBenchClients));
|
|
cfg.cliProbeOpsPerCommand = std::max(cfg.cliProbeOpsPerCommand, cfg.stressRounds);
|
|
if (cfg.cliSearchQuery.empty())
|
|
cfg.cliSearchQuery = cfg.searchQuery;
|
|
if (cfg.cliSearchType.empty())
|
|
cfg.cliSearchType = cfg.searchType;
|
|
if (cfg.cliSearchLimit == 0)
|
|
cfg.cliSearchLimit = cfg.searchLimit;
|
|
if (auto* v = std::getenv("YAMS_TUNING_PROFILE"))
|
|
cfg.tuningProfile = v;
|
|
|
|
// Storage path override: point at an existing corpus
|
|
if (auto* v = std::getenv("YAMS_BENCH_DATA_DIR"))
|
|
cfg.dataDir = fs::path(v);
|
|
if (auto* v = std::getenv("YAMS_BENCH_CONFIG"))
|
|
cfg.configPath = fs::path(v);
|
|
|
|
// Plugin / embedding configuration
|
|
if (auto* v = std::getenv("YAMS_BENCH_ENABLE_PLUGINS")) {
|
|
std::string s(v);
|
|
cfg.enablePlugins = (s == "1" || s == "true" || s == "yes");
|
|
}
|
|
if (auto* v = std::getenv("YAMS_BENCH_PLUGIN_DIR"))
|
|
cfg.pluginDir = fs::path(v);
|
|
if (auto* v = std::getenv("YAMS_BENCH_TRUSTED_PLUGIN_PATHS")) {
|
|
// Colon-separated list of paths
|
|
std::string paths(v);
|
|
std::istringstream iss(paths);
|
|
std::string path;
|
|
while (std::getline(iss, path, ':')) {
|
|
if (!path.empty())
|
|
cfg.trustedPluginPaths.push_back(fs::path(path));
|
|
}
|
|
}
|
|
if (auto* v = std::getenv("YAMS_BENCH_REAL_MODEL_PROVIDER")) {
|
|
std::string s(v);
|
|
cfg.useRealModelProvider = (s == "1" || s == "true" || s == "yes");
|
|
}
|
|
|
|
if (auto* v = std::getenv("YAMS_BENCH_USE_MCP")) {
|
|
std::string s(v);
|
|
cfg.useMcpPath = (s == "1" || s == "true" || s == "yes");
|
|
}
|
|
if (auto* v = std::getenv("YAMS_BENCH_USAGE_PROFILE")) {
|
|
cfg.usageProfile = v;
|
|
}
|
|
if (auto* v = std::getenv("YAMS_BENCH_BACKEND")) {
|
|
cfg.backend = v;
|
|
}
|
|
if (auto* v = std::getenv("YAMS_BENCH_AUTH_MODE")) {
|
|
cfg.authMode = v;
|
|
}
|
|
|
|
return cfg;
|
|
}
|
|
};
|
|
|
|
struct OutputLivenessCounters {
|
|
std::atomic<int64_t> attempts{0};
|
|
std::atomic<int64_t> nonemptySuccesses{0};
|
|
std::atomic<int64_t> emptySuccesses{0};
|
|
std::atomic<int64_t> errors{0};
|
|
|
|
void recordSuccess(bool nonempty) {
|
|
attempts.fetch_add(1, std::memory_order_relaxed);
|
|
(nonempty ? nonemptySuccesses : emptySuccesses).fetch_add(1, std::memory_order_relaxed);
|
|
}
|
|
|
|
void recordError() {
|
|
attempts.fetch_add(1, std::memory_order_relaxed);
|
|
errors.fetch_add(1, std::memory_order_relaxed);
|
|
}
|
|
|
|
[[nodiscard]] json toJson() const {
|
|
return {
|
|
{"attempts", attempts.load(std::memory_order_relaxed)},
|
|
{"nonempty_successes", nonemptySuccesses.load(std::memory_order_relaxed)},
|
|
{"empty_successes", emptySuccesses.load(std::memory_order_relaxed)},
|
|
{"errors", errors.load(std::memory_order_relaxed)},
|
|
};
|
|
}
|
|
};
|
|
|
|
TEST_CASE("Multi-client benchmark env parsing clamps extreme numeric values",
|
|
"[multi-client][config]") {
|
|
ScopedEnvVar clients("YAMS_BENCH_NUM_CLIENTS", std::string("999999999999999999999"));
|
|
ScopedEnvVar docs("YAMS_BENCH_DOCS_PER_CLIENT", std::string("999999999999999999999"));
|
|
ScopedEnvVar docSize("YAMS_BENCH_DOC_SIZE_BYTES", std::string("999999999999999999999"));
|
|
ScopedEnvVar ratio("YAMS_BENCH_SEARCH_RATIO", std::string("inf"));
|
|
ScopedEnvVar scaling("YAMS_BENCH_SCALING_MAX_CLIENTS", std::string("999999999999999999999"));
|
|
ScopedEnvVar mixedReaders("YAMS_BENCH_MIXED_READER_OPS", std::string("999999999999999999999"));
|
|
|
|
const auto cfg = BenchConfig::fromEnv();
|
|
|
|
CHECK(cfg.numClients == kMaxBenchClients);
|
|
CHECK(cfg.docsPerClient == kMaxBenchDocsPerClient);
|
|
CHECK(cfg.docSizeBytes == kMaxBenchDocSizeBytes);
|
|
CHECK(cfg.searchRatio == 0.2);
|
|
CHECK(cfg.scalingMaxClients == kMaxBenchClients);
|
|
CHECK(cfg.mixedOpsPerReader == kMaxBenchOpsPerClient);
|
|
}
|
|
|
|
std::vector<std::string> buildCliSearchArgs(const BenchConfig& cfg) {
|
|
std::vector<std::string> args{
|
|
"search",
|
|
cfg.cliSearchQuery,
|
|
"--limit",
|
|
std::to_string(cfg.cliSearchLimit),
|
|
};
|
|
if (!cfg.cliSearchType.empty()) {
|
|
args.push_back("--type");
|
|
args.push_back(cfg.cliSearchType);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
class NullTransport : public yams::mcp::ITransport {
|
|
public:
|
|
void send(const nlohmann::json&) override {}
|
|
yams::mcp::MessageResult receive() override {
|
|
return yams::Error{yams::ErrorCode::NotImplemented, "Null transport"};
|
|
}
|
|
bool isConnected() const override { return false; }
|
|
void close() override {}
|
|
yams::mcp::TransportState getState() const override {
|
|
return yams::mcp::TransportState::Disconnected;
|
|
}
|
|
};
|
|
|
|
class MCPPipelineClient {
|
|
public:
|
|
MCPPipelineClient() {
|
|
server_ = std::make_shared<yams::mcp::MCPServer>(std::make_unique<NullTransport>());
|
|
initialize();
|
|
}
|
|
|
|
yams::Result<nlohmann::json> queryStep(const std::string& op, const nlohmann::json& params) {
|
|
std::lock_guard<std::mutex> lock(mu_);
|
|
return queryStepUnlocked(op, params);
|
|
}
|
|
|
|
private:
|
|
static bool isQueryCompositeReadOp(const std::string& op) {
|
|
return op == "search" || op == "grep" || op == "list" || op == "list_collections" ||
|
|
op == "list_snapshots" || op == "graph" || op == "get" || op == "status";
|
|
}
|
|
|
|
yams::Result<nlohmann::json> queryStepUnlocked(const std::string& op,
|
|
const nlohmann::json& params) {
|
|
nlohmann::json request = nlohmann::json::object();
|
|
request["jsonrpc"] = "2.0";
|
|
request["id"] = requestId_++;
|
|
request["method"] = "tools/call";
|
|
request["params"] = nlohmann::json::object();
|
|
const bool useQueryTool = hasQueryTool_ && isQueryCompositeReadOp(op);
|
|
request["params"]["name"] = useQueryTool ? "query" : op;
|
|
if (useQueryTool) {
|
|
request["params"]["arguments"] = nlohmann::json::object();
|
|
request["params"]["arguments"]["steps"] =
|
|
nlohmann::json::array({{{"op", op}, {"params", params}}});
|
|
} else {
|
|
request["params"]["arguments"] = params.is_object() ? params : nlohmann::json::object();
|
|
}
|
|
|
|
auto responseResult = server_->processMessage(request);
|
|
if (!responseResult) {
|
|
return yams::Error{responseResult.error()};
|
|
}
|
|
|
|
const auto& response = responseResult.value();
|
|
if (response.contains("error")) {
|
|
const std::string msg = response["error"].value("message", "MCP tools/call error");
|
|
if (hasQueryTool_ && msg.rfind("Unknown tool: query", 0) == 0) {
|
|
hasQueryTool_ = false;
|
|
return queryStepUnlocked(op, params);
|
|
}
|
|
return yams::Error{yams::ErrorCode::InvalidState, msg};
|
|
}
|
|
if (!response.contains("result") || !response["result"].is_object()) {
|
|
return yams::Error{yams::ErrorCode::InvalidState,
|
|
"MCP tools/call missing result object"};
|
|
}
|
|
|
|
const auto& result = response["result"];
|
|
if (result.value("isError", false)) {
|
|
std::string msg{"MCP tool returned error"};
|
|
if (result.contains("content") && result["content"].is_array() &&
|
|
!result["content"].empty() && result["content"][0].contains("text")) {
|
|
msg = result["content"][0]["text"].get<std::string>();
|
|
}
|
|
return yams::Error{yams::ErrorCode::InvalidState, msg};
|
|
}
|
|
|
|
if (result.contains("structuredContent") && result["structuredContent"].is_object()) {
|
|
const auto& sc = result["structuredContent"];
|
|
if (sc.contains("data")) {
|
|
return sc["data"];
|
|
}
|
|
}
|
|
|
|
if (result.contains("content") && result["content"].is_array() &&
|
|
!result["content"].empty() && result["content"][0].contains("text")) {
|
|
try {
|
|
return nlohmann::json::parse(result["content"][0]["text"].get<std::string>());
|
|
} catch (...) {
|
|
return nlohmann::json{{"text", result["content"][0]["text"]}};
|
|
}
|
|
}
|
|
|
|
return yams::Error{yams::ErrorCode::InvalidState, "MCP tool result had no data"};
|
|
}
|
|
|
|
void initialize() {
|
|
nlohmann::json initReq = nlohmann::json::object();
|
|
initReq["jsonrpc"] = "2.0";
|
|
initReq["id"] = requestId_++;
|
|
initReq["method"] = "initialize";
|
|
initReq["params"] = nlohmann::json::object();
|
|
initReq["params"]["protocolVersion"] = "2025-06-18";
|
|
initReq["params"]["capabilities"] = nlohmann::json::object();
|
|
initReq["params"]["clientInfo"] =
|
|
nlohmann::json{{"name", "multi_client_ingestion_bench"}, {"version", "1.0"}};
|
|
auto initRes = server_->processMessage(initReq);
|
|
if (!initRes) {
|
|
throw std::runtime_error("MCP initialize failed: " + initRes.error().message);
|
|
}
|
|
|
|
nlohmann::json initializedNotif = {{"jsonrpc", "2.0"},
|
|
{"method", "notifications/initialized"},
|
|
{"params", nlohmann::json::object()}};
|
|
(void)server_->processMessage(initializedNotif);
|
|
|
|
nlohmann::json toolsReq = nlohmann::json::object();
|
|
toolsReq["jsonrpc"] = "2.0";
|
|
toolsReq["id"] = requestId_++;
|
|
toolsReq["method"] = "tools/list";
|
|
toolsReq["params"] = nlohmann::json::object();
|
|
auto toolsRes = server_->processMessage(toolsReq);
|
|
if (!toolsRes) {
|
|
throw std::runtime_error("MCP tools/list failed: " + toolsRes.error().message);
|
|
}
|
|
const auto& toolsResp = toolsRes.value();
|
|
if (!toolsResp.contains("result") || !toolsResp["result"].is_object() ||
|
|
!toolsResp["result"].contains("tools") || !toolsResp["result"]["tools"].is_array()) {
|
|
throw std::runtime_error("MCP tools/list returned invalid shape");
|
|
}
|
|
hasQueryTool_ = false;
|
|
for (const auto& tool : toolsResp["result"]["tools"]) {
|
|
if (!tool.is_object() || !tool.contains("name") || !tool["name"].is_string()) {
|
|
continue;
|
|
}
|
|
if (tool["name"].get<std::string>() == "query") {
|
|
hasQueryTool_ = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
std::shared_ptr<yams::mcp::MCPServer> server_;
|
|
std::mutex mu_;
|
|
bool hasQueryTool_{false};
|
|
std::uint64_t requestId_{1};
|
|
};
|
|
|
|
class MCPPipelineClientPool {
|
|
public:
|
|
MCPPipelineClientPool(const fs::path& socketPath, size_t poolSize)
|
|
: socketEnvironment_("YAMS_DAEMON_SOCKET_PATH", socketPath.string()),
|
|
autoStartEnvironment_("YAMS_CLI_DISABLE_DAEMON_AUTOSTART", std::string{"1"}) {
|
|
const size_t size = std::max<size_t>(1, poolSize);
|
|
clients_.reserve(size);
|
|
for (size_t i = 0; i < size; ++i) {
|
|
clients_.push_back(std::make_unique<MCPPipelineClient>());
|
|
}
|
|
}
|
|
|
|
yams::Result<nlohmann::json> queryStepForSlot(size_t slot, const std::string& op,
|
|
const nlohmann::json& params) {
|
|
const size_t idx = slot % clients_.size();
|
|
return clients_[idx]->queryStep(op, params);
|
|
}
|
|
|
|
private:
|
|
yams::test::ScopedEnvVar socketEnvironment_;
|
|
yams::test::ScopedEnvVar autoStartEnvironment_;
|
|
std::vector<std::unique_ptr<MCPPipelineClient>> clients_;
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Latency & throughput tracking
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
struct OpLatency {
|
|
std::string opType; // "add", "search", "list", "status"
|
|
int clientId{0};
|
|
int64_t latencyUs{0};
|
|
bool success{false};
|
|
std::string errorMsg;
|
|
};
|
|
|
|
struct ClientResult {
|
|
int clientId{0};
|
|
int docsIngested{0};
|
|
int searchesExecuted{0};
|
|
int listsExecuted{0};
|
|
int statusChecks{0};
|
|
int failures{0};
|
|
double elapsedSeconds{0.0};
|
|
double addThroughputDocsPerSec{0.0};
|
|
std::vector<OpLatency> latencies;
|
|
std::vector<int64_t> snippetHydrationUs;
|
|
std::vector<int64_t> snippetLookupUs;
|
|
std::vector<int64_t> snippetContentFetchUs;
|
|
std::vector<int64_t> snippetRenderUs;
|
|
int64_t snippetTimeoutHits{0};
|
|
int64_t snippetHydrationCandidates{0};
|
|
int64_t snippetHydrationMissing{0};
|
|
int64_t snippetHydratedResults{0};
|
|
};
|
|
|
|
double safeRate(double numerator, double elapsedSeconds) {
|
|
return (numerator > 0.0 && elapsedSeconds > 0.0) ? numerator / elapsedSeconds : 0.0;
|
|
}
|
|
|
|
struct PercentileStats {
|
|
int64_t min{0};
|
|
int64_t p50{0};
|
|
int64_t p90{0};
|
|
int64_t p95{0};
|
|
int64_t p99{0};
|
|
int64_t max{0};
|
|
double mean{0.0};
|
|
size_t count{0};
|
|
|
|
static PercentileStats compute(std::vector<int64_t>& values) {
|
|
values.erase(
|
|
std::remove_if(values.begin(), values.end(), [](int64_t value) { return value < 0; }),
|
|
values.end());
|
|
for (auto& value : values) {
|
|
value = clampMetricSample(value);
|
|
}
|
|
|
|
PercentileStats stats;
|
|
stats.count = values.size();
|
|
if (values.empty())
|
|
return stats;
|
|
|
|
std::sort(values.begin(), values.end());
|
|
const auto percentileIndex = [&](double percentile) {
|
|
return std::min(values.size() - 1,
|
|
static_cast<size_t>(percentile * static_cast<double>(values.size())));
|
|
};
|
|
stats.min = values.front();
|
|
stats.max = values.back();
|
|
stats.p50 = values[percentileIndex(0.50)];
|
|
stats.p90 = values[percentileIndex(0.90)];
|
|
stats.p95 = values[percentileIndex(0.95)];
|
|
stats.p99 = values[percentileIndex(0.99)];
|
|
const long double total =
|
|
std::accumulate(values.begin(), values.end(), 0.0L, [](long double sum, int64_t value) {
|
|
return sum + static_cast<long double>(value);
|
|
});
|
|
stats.mean = static_cast<double>(total / static_cast<long double>(values.size()));
|
|
return stats;
|
|
}
|
|
|
|
json toJson() const {
|
|
return json{{"count", count}, {"min_us", min}, {"p50_us", p50}, {"p90_us", p90},
|
|
{"p95_us", p95}, {"p99_us", p99}, {"max_us", max}, {"mean_us", mean}};
|
|
}
|
|
};
|
|
|
|
std::optional<fs::path> findExecutableInPath(const std::string& name) {
|
|
if (const char* pathEnv = std::getenv("PATH")) {
|
|
std::stringstream ss(pathEnv);
|
|
std::string entry;
|
|
while (std::getline(ss, entry, ':')) {
|
|
if (entry.empty()) {
|
|
continue;
|
|
}
|
|
fs::path candidate = fs::path(entry) / name;
|
|
std::error_code ec;
|
|
if (fs::exists(candidate, ec) && !ec) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
struct CliBinarySelection {
|
|
std::optional<fs::path> path{};
|
|
std::string source{"missing"};
|
|
std::string note;
|
|
bool explicitSelection{false};
|
|
bool pathFallback{false};
|
|
bool usableForStress{false};
|
|
|
|
json toJson() const {
|
|
return json{{"path", path ? path->string() : std::string()},
|
|
{"source", source},
|
|
{"note", note},
|
|
{"explicit_selection", explicitSelection},
|
|
{"path_fallback", pathFallback},
|
|
{"usable_for_stress", usableForStress}};
|
|
}
|
|
};
|
|
|
|
bool isExecutableBinary(const fs::path& candidate) {
|
|
std::error_code ec;
|
|
if (!fs::exists(candidate, ec) || ec) {
|
|
return false;
|
|
}
|
|
#ifdef _WIN32
|
|
return true;
|
|
#else
|
|
return ::access(candidate.c_str(), X_OK) == 0;
|
|
#endif
|
|
}
|
|
|
|
CliBinarySelection findYamsBinary(const BenchConfig& cfg) {
|
|
auto resolveCandidate = [](const fs::path& candidate) -> std::optional<fs::path> {
|
|
if (!isExecutableBinary(candidate)) {
|
|
return std::nullopt;
|
|
}
|
|
return fs::absolute(candidate);
|
|
};
|
|
|
|
if (cfg.cliBinary && fs::exists(cfg.cliBinary.value())) {
|
|
CliBinarySelection selection;
|
|
selection.path = fs::absolute(cfg.cliBinary.value());
|
|
selection.source = "explicit";
|
|
selection.explicitSelection = true;
|
|
selection.usableForStress = true;
|
|
return selection;
|
|
}
|
|
|
|
if (const char* buildRoot = std::getenv("MESON_BUILD_ROOT")) {
|
|
for (const auto& relative : {fs::path("tools/yams-cli/yams-cli"),
|
|
fs::path("tools/yams-cli/yams"), fs::path("src/cli/yams")}) {
|
|
#ifdef _WIN32
|
|
const fs::path candidate = fs::path(buildRoot) / relative.replace_extension(".exe");
|
|
#else
|
|
const fs::path candidate = fs::path(buildRoot) / relative;
|
|
#endif
|
|
if (auto resolved = resolveCandidate(candidate)) {
|
|
CliBinarySelection selection;
|
|
selection.path = resolved.value();
|
|
selection.source = "meson_build_root";
|
|
selection.usableForStress = true;
|
|
return selection;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const auto& relative :
|
|
{fs::path("build/debug/tools/yams-cli/yams-cli"),
|
|
fs::path("build/tools/yams-cli/yams-cli"),
|
|
fs::path("build/debug/tools/yams-cli/yams-cli"),
|
|
fs::path("build/release/tools/yams-cli/yams-cli"),
|
|
fs::path("build/asan/tools/yams-cli/yams-cli"),
|
|
fs::path("build/opencode-tests/tools/yams-cli/yams-cli"),
|
|
fs::path("build/debug/src/cli/yams"), fs::path("build/src/cli/yams"),
|
|
fs::path("build/debug/src/cli/yams"), fs::path("build/release/src/cli/yams"),
|
|
fs::path("build/asan/src/cli/yams"), fs::path("build/opencode-tests/src/cli/yams")}) {
|
|
#ifdef _WIN32
|
|
const fs::path candidate = relative.replace_extension(".exe");
|
|
#else
|
|
const fs::path candidate = relative;
|
|
#endif
|
|
if (auto resolved = resolveCandidate(candidate)) {
|
|
CliBinarySelection selection;
|
|
selection.path = resolved.value();
|
|
selection.source = "local_build_tree";
|
|
selection.usableForStress = true;
|
|
return selection;
|
|
}
|
|
}
|
|
|
|
#ifdef _WIN32
|
|
if (auto candidate = findExecutableInPath("yams.exe")) {
|
|
CliBinarySelection selection;
|
|
selection.path = candidate.value();
|
|
selection.source = "path_fallback";
|
|
selection.note =
|
|
"PATH fallback may not match the build under test; set YAMS_BENCH_CLI_BIN for "
|
|
"deterministic CLI stress results";
|
|
selection.pathFallback = true;
|
|
selection.usableForStress = false;
|
|
return selection;
|
|
}
|
|
#else
|
|
if (auto candidate = findExecutableInPath("yams")) {
|
|
CliBinarySelection selection;
|
|
selection.path = candidate.value();
|
|
selection.source = "path_fallback";
|
|
selection.note =
|
|
"PATH fallback may not match the build under test; set YAMS_BENCH_CLI_BIN for "
|
|
"deterministic CLI stress results";
|
|
selection.pathFallback = true;
|
|
selection.usableForStress = false;
|
|
return selection;
|
|
}
|
|
#endif
|
|
|
|
return CliBinarySelection{.source = "missing",
|
|
.note =
|
|
"No in-tree CLI binary found; set YAMS_BENCH_CLI_BIN or build "
|
|
"tools/yams-cli/yams-cli to enable CLI stress probes",
|
|
.usableForStress = false};
|
|
}
|
|
|
|
struct ProbeDaemonState {
|
|
bool captured{false};
|
|
size_t activeConnections{0};
|
|
size_t maxConnections{0};
|
|
size_t connectionSlotsFree{0};
|
|
int pressureLevel{0};
|
|
uint32_t retryAfterMs{0};
|
|
std::string error;
|
|
|
|
bool underStress() const {
|
|
if (captured) {
|
|
return (maxConnections > 0 && activeConnections >= maxConnections) ||
|
|
connectionSlotsFree == 0 || retryAfterMs > 0 || pressureLevel >= 1;
|
|
}
|
|
if (error.empty()) {
|
|
return false;
|
|
}
|
|
std::string lower;
|
|
lower.reserve(error.size());
|
|
for (unsigned char ch : error) {
|
|
lower.push_back(static_cast<char>(std::tolower(ch)));
|
|
}
|
|
auto has = [&lower](std::string_view needle) {
|
|
return lower.find(needle) != std::string::npos;
|
|
};
|
|
return has("ipc:eof") || has("eof") || has("end of file") || has("timed out") ||
|
|
has("timeout") || has("connection reset") || has("broken pipe") ||
|
|
has("resource exhausted") || has("retry after") || has("server busy") ||
|
|
has("bad file descriptor");
|
|
}
|
|
|
|
json toJson() const {
|
|
return json{{"captured", captured},
|
|
{"active_connections", activeConnections},
|
|
{"max_connections", maxConnections},
|
|
{"connection_slots_free", connectionSlotsFree},
|
|
{"pressure_level", pressureLevel},
|
|
{"retry_after_ms", retryAfterMs},
|
|
{"error", error}};
|
|
}
|
|
};
|
|
|
|
struct CliProbeResult {
|
|
bool attempted{false};
|
|
bool success{false};
|
|
int exitCode{-1};
|
|
int signal{-1};
|
|
int64_t latencyUs{0};
|
|
bool timedOut{false};
|
|
std::string opType;
|
|
std::string error;
|
|
std::string stderrText;
|
|
std::string failureBucket;
|
|
std::string failureMode;
|
|
ProbeDaemonState beforeState;
|
|
ProbeDaemonState afterState;
|
|
std::unordered_map<std::string, int64_t> stageElapsedUs;
|
|
|
|
json toJson() const {
|
|
return json{{"attempted", attempted},
|
|
{"success", success},
|
|
{"op_type", opType},
|
|
{"exit_code", exitCode},
|
|
{"signal", signal},
|
|
{"latency_us", latencyUs},
|
|
{"timed_out", timedOut},
|
|
{"error", error},
|
|
{"stderr", stderrText},
|
|
{"failure_bucket", failureBucket},
|
|
{"failure_mode", failureMode},
|
|
{"before_state", beforeState.toJson()},
|
|
{"after_state", afterState.toJson()},
|
|
{"stage_elapsed_us", stageElapsedUs}};
|
|
}
|
|
};
|
|
|
|
#ifndef _WIN32
|
|
namespace {} // namespace
|
|
|
|
CliProbeResult runCliProbe(const fs::path& yamsBinary, const fs::path& socketPath,
|
|
const std::vector<std::string>& args,
|
|
std::chrono::milliseconds timeout = 10s) {
|
|
CliProbeResult result;
|
|
result.attempted = true;
|
|
|
|
int sinkFd = ::open("/dev/null", O_WRONLY);
|
|
if (sinkFd < 0) {
|
|
result.error = "open(/dev/null) failed";
|
|
return result;
|
|
}
|
|
int tracePipe[2] = {-1, -1};
|
|
if (::pipe(tracePipe) != 0) {
|
|
::close(sinkFd);
|
|
result.error = "pipe() failed";
|
|
return result;
|
|
}
|
|
|
|
std::vector<std::string> owned;
|
|
owned.reserve(args.size() + 1);
|
|
owned.push_back(yamsBinary.string());
|
|
owned.insert(owned.end(), args.begin(), args.end());
|
|
|
|
std::vector<char*> argv;
|
|
argv.reserve(owned.size() + 1);
|
|
for (auto& arg : owned) {
|
|
argv.push_back(arg.data());
|
|
}
|
|
argv.push_back(nullptr);
|
|
|
|
std::vector<std::string> envOwned;
|
|
if (environ != nullptr) {
|
|
for (char** env = environ; *env != nullptr; ++env) {
|
|
envOwned.emplace_back(*env);
|
|
}
|
|
}
|
|
auto setOrReplaceEnv = [&](std::string key, std::string value) {
|
|
const std::string prefix = key + "=";
|
|
for (auto& entry : envOwned) {
|
|
if (entry.rfind(prefix, 0) == 0) {
|
|
entry = prefix + value;
|
|
return;
|
|
}
|
|
}
|
|
envOwned.push_back(prefix + value);
|
|
};
|
|
setOrReplaceEnv("YAMS_DAEMON_SOCKET_PATH", socketPath.string());
|
|
setOrReplaceEnv("YAMS_CLI_DISABLE_DAEMON_AUTOSTART", "1");
|
|
setOrReplaceEnv("YAMS_CLI_ONE_SHOT", "1");
|
|
setOrReplaceEnv("YAMS_CLI_PERF_TRACE", "1");
|
|
setOrReplaceEnv("YAMS_LOG_LEVEL", "error");
|
|
|
|
std::vector<char*> envp;
|
|
envp.reserve(envOwned.size() + 1);
|
|
for (auto& entry : envOwned) {
|
|
envp.push_back(entry.data());
|
|
}
|
|
envp.push_back(nullptr);
|
|
|
|
posix_spawn_file_actions_t actions;
|
|
if (::posix_spawn_file_actions_init(&actions) != 0) {
|
|
::close(sinkFd);
|
|
::close(tracePipe[0]);
|
|
::close(tracePipe[1]);
|
|
result.error = "posix_spawn file actions init failed";
|
|
return result;
|
|
}
|
|
(void)::posix_spawn_file_actions_adddup2(&actions, sinkFd, STDOUT_FILENO);
|
|
(void)::posix_spawn_file_actions_adddup2(&actions, tracePipe[1], STDERR_FILENO);
|
|
(void)::posix_spawn_file_actions_addclose(&actions, sinkFd);
|
|
(void)::posix_spawn_file_actions_addclose(&actions, tracePipe[0]);
|
|
(void)::posix_spawn_file_actions_addclose(&actions, tracePipe[1]);
|
|
|
|
posix_spawnattr_t attr;
|
|
(void)::posix_spawnattr_init(&attr);
|
|
#ifdef POSIX_SPAWN_CLOEXEC_DEFAULT
|
|
short spawnFlags = 0;
|
|
spawnFlags |= POSIX_SPAWN_CLOEXEC_DEFAULT;
|
|
(void)::posix_spawnattr_setflags(&attr, spawnFlags);
|
|
#endif
|
|
|
|
const auto start = std::chrono::steady_clock::now();
|
|
pid_t pid = -1;
|
|
const int spawnErr =
|
|
::posix_spawn(&pid, yamsBinary.c_str(), &actions, &attr, argv.data(), envp.data());
|
|
(void)::posix_spawn_file_actions_destroy(&actions);
|
|
(void)::posix_spawnattr_destroy(&attr);
|
|
if (spawnErr != 0) {
|
|
::close(sinkFd);
|
|
::close(tracePipe[0]);
|
|
::close(tracePipe[1]);
|
|
result.error = "posix_spawn() failed: " + std::string(std::strerror(spawnErr));
|
|
return result;
|
|
}
|
|
|
|
::close(sinkFd);
|
|
::close(tracePipe[1]);
|
|
int status = 0;
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
for (;;) {
|
|
pid_t waited = ::waitpid(pid, &status, WNOHANG);
|
|
if (waited == pid) {
|
|
break;
|
|
}
|
|
if (waited < 0) {
|
|
if (errno == EINTR) {
|
|
continue;
|
|
}
|
|
result.error = "waitpid() failed";
|
|
break;
|
|
}
|
|
if (std::chrono::steady_clock::now() >= deadline) {
|
|
::kill(pid, SIGKILL);
|
|
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
|
}
|
|
result.timedOut = true;
|
|
result.error = "cli probe timed out";
|
|
result.exitCode = 124;
|
|
result.latencyUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
|
|
result.latencyUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
|
|
if (WIFSIGNALED(status)) {
|
|
result.signal = WTERMSIG(status);
|
|
result.error = "cli probe terminated by signal " + std::to_string(result.signal);
|
|
} else if (WIFEXITED(status)) {
|
|
result.exitCode = WEXITSTATUS(status);
|
|
result.success = (result.exitCode == 0);
|
|
} else {
|
|
result.error = "child terminated abnormally";
|
|
}
|
|
|
|
std::string trace;
|
|
std::array<char, 1024> buffer{};
|
|
for (;;) {
|
|
ssize_t n = ::read(tracePipe[0], buffer.data(), buffer.size());
|
|
if (n > 0) {
|
|
trace.append(buffer.data(), static_cast<size_t>(n));
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
::close(tracePipe[0]);
|
|
|
|
std::stringstream traceStream(trace);
|
|
std::string line;
|
|
std::vector<std::string> stderrLines;
|
|
while (std::getline(traceStream, line)) {
|
|
constexpr std::string_view kPrefix = "[yams-cli-perf] stage=";
|
|
auto prefixPos = line.find(kPrefix);
|
|
if (prefixPos == std::string::npos) {
|
|
if (!line.empty()) {
|
|
stderrLines.push_back(line);
|
|
}
|
|
continue;
|
|
}
|
|
auto stageStart = prefixPos + kPrefix.size();
|
|
auto elapsedPos = line.find(" elapsed_us=", stageStart);
|
|
if (elapsedPos == std::string::npos) {
|
|
continue;
|
|
}
|
|
auto stage = line.substr(stageStart, elapsedPos - stageStart);
|
|
auto valueStart = elapsedPos + std::string_view(" elapsed_us=").size();
|
|
auto valueEnd = line.find(' ', valueStart);
|
|
auto valueText = line.substr(
|
|
valueStart, valueEnd == std::string::npos ? std::string::npos : valueEnd - valueStart);
|
|
try {
|
|
result.stageElapsedUs[stage] = std::stoll(valueText);
|
|
} catch (...) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!stderrLines.empty()) {
|
|
std::ostringstream stderrBuf;
|
|
for (size_t i = 0; i < stderrLines.size(); ++i) {
|
|
if (i > 0) {
|
|
stderrBuf << '\n';
|
|
}
|
|
stderrBuf << stderrLines[i];
|
|
}
|
|
result.stderrText = stderrBuf.str();
|
|
}
|
|
|
|
if (!result.success && result.error.empty()) {
|
|
if (!result.stderrText.empty()) {
|
|
result.error = result.stderrText;
|
|
} else if (result.exitCode >= 0) {
|
|
result.error = "cli probe exited with code " + std::to_string(result.exitCode);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
#endif
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Daemon status snapshot helper
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
struct FdCategoryCounts {
|
|
uint64_t total{0};
|
|
uint64_t socket{0};
|
|
uint64_t pipe{0};
|
|
uint64_t file{0};
|
|
uint64_t device{0};
|
|
uint64_t kqueue{0};
|
|
uint64_t other{0};
|
|
};
|
|
|
|
FdCategoryCounts countOpenFileDescriptorCategories() {
|
|
FdCategoryCounts counts;
|
|
#ifdef _WIN32
|
|
return counts;
|
|
#else
|
|
DIR* dir = opendir("/dev/fd");
|
|
if (!dir) {
|
|
return counts;
|
|
}
|
|
|
|
while (auto* entry = readdir(dir)) {
|
|
const std::string_view name(entry->d_name);
|
|
if (name != "." && name != "..") {
|
|
++counts.total;
|
|
char* end = nullptr;
|
|
const auto fdNumber = std::strtol(entry->d_name, &end, 10);
|
|
if (end && *end == '\0' && fdNumber >= 0) {
|
|
struct stat st{};
|
|
if (::fstat(static_cast<int>(fdNumber), &st) == 0) {
|
|
if (S_ISSOCK(st.st_mode)) {
|
|
++counts.socket;
|
|
} else if (S_ISFIFO(st.st_mode)) {
|
|
++counts.pipe;
|
|
} else if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode)) {
|
|
++counts.file;
|
|
} else if (S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode)) {
|
|
++counts.device;
|
|
} else {
|
|
++counts.other;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
const std::string fdPath = std::string{"/dev/fd/"} + std::string{name};
|
|
std::array<char, PATH_MAX + 1> target{};
|
|
const auto len = ::readlink(fdPath.c_str(), target.data(), PATH_MAX);
|
|
if (len < 0) {
|
|
++counts.other;
|
|
continue;
|
|
}
|
|
target[static_cast<std::size_t>(len)] = '\0';
|
|
const std::string_view t(target.data(), static_cast<std::size_t>(len));
|
|
if (t.find("socket") != std::string_view::npos) {
|
|
++counts.socket;
|
|
} else if (t.find("pipe") != std::string_view::npos) {
|
|
++counts.pipe;
|
|
} else if (t.find("kqueue") != std::string_view::npos) {
|
|
++counts.kqueue;
|
|
} else if (t.starts_with("/dev/")) {
|
|
++counts.device;
|
|
} else if (t.starts_with("/") || t.starts_with(".")) {
|
|
++counts.file;
|
|
} else {
|
|
++counts.other;
|
|
}
|
|
}
|
|
}
|
|
closedir(dir);
|
|
return counts;
|
|
#endif
|
|
}
|
|
|
|
constexpr uint64_t backgroundWorkerActive(uint64_t observedWorkerActive) {
|
|
// StatusRequest is itself tracked as an active worker before its snapshot is captured.
|
|
return observedWorkerActive > 0 ? observedWorkerActive - 1 : 0;
|
|
}
|
|
|
|
struct DaemonSnapshot {
|
|
size_t activeConnections{0};
|
|
size_t maxConnections{0};
|
|
size_t connectionSlotsFree{0};
|
|
uint64_t oldestConnectionAge{0};
|
|
uint64_t forcedCloseCount{0};
|
|
uint64_t requestsProcessed{0};
|
|
double memoryUsageMb{0.0};
|
|
double cpuUsagePercent{0.0};
|
|
uint64_t rssBytes{0};
|
|
uint64_t postIngestQueued{0};
|
|
uint64_t postIngestInflight{0};
|
|
uint64_t workerActive{0};
|
|
uint64_t workerQueued{0};
|
|
uint64_t deferredQueueDepth{0};
|
|
int pressureLevel{0};
|
|
uint64_t documentsTotal{0};
|
|
uint64_t searchActive{0};
|
|
uint64_t searchQueued{0};
|
|
uint64_t dbWritePoolWaiting{0};
|
|
uint64_t dbReadPoolWaiting{0};
|
|
uint64_t dbWritePoolMaxObservedWaiting{0};
|
|
uint64_t dbReadPoolMaxObservedWaiting{0};
|
|
uint64_t dbWritePoolTotalWaitMicros{0};
|
|
uint64_t dbReadPoolTotalWaitMicros{0};
|
|
uint64_t dbWritePoolTimeoutCount{0};
|
|
uint64_t dbReadPoolTimeoutCount{0};
|
|
uint64_t dbWritePoolFailedAcquisitions{0};
|
|
uint64_t dbReadPoolFailedAcquisitions{0};
|
|
uint64_t dbWritePoolSlowHolders{0};
|
|
uint64_t dbReadPoolSlowHolders{0};
|
|
uint64_t dbWritePoolMaxHolderMicros{0};
|
|
uint64_t dbReadPoolMaxHolderMicros{0};
|
|
uint64_t muxWriterBudgetBytes{0};
|
|
uint64_t writeQueueDepth{0};
|
|
uint64_t writeQueueCapacity{0};
|
|
uint64_t writeQueueDepthMax{0};
|
|
uint64_t writeQueueCapacityRejections{0};
|
|
uint64_t writeQueueForcedOverCapacity{0};
|
|
uint64_t writeInFlight{0};
|
|
uint64_t writeMaxBatchApplyMs{0};
|
|
uint64_t writeMaxBatchQueueWaitMs{0};
|
|
uint64_t writeMaxBatchExcessQueueWaitMs{0};
|
|
uint64_t processFdCount{0};
|
|
uint64_t processFdSockets{0};
|
|
uint64_t processFdPipes{0};
|
|
uint64_t processFdFiles{0};
|
|
uint64_t processFdDevices{0};
|
|
uint64_t processFdKqueues{0};
|
|
uint64_t processFdOther{0};
|
|
uint64_t postCommitContentIndexCalls{0};
|
|
uint64_t postCommitContentIndexTotalMs{0};
|
|
uint64_t postCommitContentIndexMaxMs{0};
|
|
uint64_t addDispatchSamples{0};
|
|
uint64_t addDispatchTotalUs{0};
|
|
uint64_t addDispatchMaxUs{0};
|
|
uint64_t addFingerprintTotalUs{0};
|
|
uint64_t addFingerprintMaxUs{0};
|
|
uint64_t addEnqueueTotalUs{0};
|
|
uint64_t addEnqueueMaxUs{0};
|
|
uint64_t clientPoolOpenSockets{0};
|
|
uint64_t clientPoolAliveConnections{0};
|
|
uint64_t clientPoolInUseConnections{0};
|
|
std::string writeHotSources;
|
|
uint32_t ipcPoolSize{0};
|
|
uint32_t ioPoolSize{0};
|
|
uint32_t retryAfterMs{0};
|
|
uint64_t metadataWalBytes{0};
|
|
|
|
static DaemonSnapshot capture(DaemonClient& client, const fs::path& dataDir = {}) {
|
|
DaemonSnapshot snap;
|
|
if (!dataDir.empty()) {
|
|
std::error_code ec;
|
|
const auto walPath = dataDir / "yams.db-wal";
|
|
if (fs::exists(walPath, ec) && !ec) {
|
|
snap.metadataWalBytes = static_cast<uint64_t>(fs::file_size(walPath, ec));
|
|
}
|
|
}
|
|
auto st = yams::cli::run_sync(client.status(), 5s);
|
|
if (!st)
|
|
return snap;
|
|
|
|
const auto fdCounts = countOpenFileDescriptorCategories();
|
|
snap.processFdCount = fdCounts.total;
|
|
snap.processFdSockets = fdCounts.socket;
|
|
snap.processFdPipes = fdCounts.pipe;
|
|
snap.processFdFiles = fdCounts.file;
|
|
snap.processFdDevices = fdCounts.device;
|
|
snap.processFdKqueues = fdCounts.kqueue;
|
|
snap.processFdOther = fdCounts.other;
|
|
const auto poolStats = AsioConnectionPool::registry_stats();
|
|
snap.clientPoolOpenSockets = poolStats.openSockets;
|
|
snap.clientPoolAliveConnections = poolStats.aliveConnections;
|
|
snap.clientPoolInUseConnections = poolStats.inUseConnections;
|
|
|
|
const auto& s = st.value();
|
|
snap.activeConnections = s.activeConnections;
|
|
snap.maxConnections = s.maxConnections;
|
|
snap.connectionSlotsFree = s.connectionSlotsFree;
|
|
snap.oldestConnectionAge = s.oldestConnectionAge;
|
|
snap.forcedCloseCount = s.forcedCloseCount;
|
|
snap.requestsProcessed = s.requestsProcessed;
|
|
snap.memoryUsageMb = s.memoryUsageMb;
|
|
snap.cpuUsagePercent = s.cpuUsagePercent;
|
|
snap.muxWriterBudgetBytes = s.muxWriterBudgetBytes;
|
|
snap.ipcPoolSize = s.ipcPoolSize;
|
|
snap.ioPoolSize = s.ioPoolSize;
|
|
snap.retryAfterMs = s.retryAfterMs;
|
|
|
|
auto getCount = [&](std::string_view key) -> uint64_t {
|
|
auto it = s.requestCounts.find(std::string(key));
|
|
return (it != s.requestCounts.end()) ? it->second : 0;
|
|
};
|
|
|
|
if (snap.maxConnections == 0) {
|
|
snap.maxConnections = static_cast<size_t>(getCount("status_max_connections"));
|
|
}
|
|
if (snap.connectionSlotsFree == 0) {
|
|
snap.connectionSlotsFree =
|
|
static_cast<size_t>(getCount("status_connection_slots_free"));
|
|
}
|
|
if (snap.oldestConnectionAge == 0) {
|
|
snap.oldestConnectionAge = getCount("status_oldest_connection_age_s");
|
|
}
|
|
if (snap.forcedCloseCount == 0) {
|
|
snap.forcedCloseCount = getCount("status_forced_close_count");
|
|
}
|
|
if (snap.muxWriterBudgetBytes == 0) {
|
|
snap.muxWriterBudgetBytes = getCount("status_mux_writer_budget_bytes");
|
|
}
|
|
if (snap.ipcPoolSize == 0) {
|
|
snap.ipcPoolSize = static_cast<uint32_t>(getCount("status_ipc_pool_size"));
|
|
}
|
|
if (snap.ioPoolSize == 0) {
|
|
snap.ioPoolSize = static_cast<uint32_t>(getCount("status_io_pool_size"));
|
|
}
|
|
if (snap.retryAfterMs == 0) {
|
|
snap.retryAfterMs = static_cast<uint32_t>(getCount("status_retry_after_ms"));
|
|
}
|
|
snap.rssBytes = getCount("status_rss_bytes");
|
|
|
|
snap.postIngestQueued = getCount(metrics::kPostIngestQueued);
|
|
snap.postIngestInflight = getCount(metrics::kPostIngestInflight);
|
|
snap.workerActive = backgroundWorkerActive(getCount(metrics::kWorkerActive));
|
|
snap.workerQueued = getCount(metrics::kWorkerQueued);
|
|
snap.deferredQueueDepth = getCount(metrics::kDeferredQueueDepth);
|
|
snap.pressureLevel = static_cast<int>(getCount(metrics::kPressureLevel));
|
|
snap.documentsTotal = getCount(metrics::kDocumentsTotal);
|
|
snap.searchActive = getCount(metrics::kSearchActive);
|
|
snap.searchQueued = getCount(metrics::kSearchQueued);
|
|
snap.dbWritePoolWaiting = getCount(metrics::kDbWritePoolWaitingRequests);
|
|
snap.dbReadPoolWaiting = getCount(metrics::kDbReadPoolWaitingRequests);
|
|
snap.addDispatchSamples = getCount("add_phase_dispatch_samples");
|
|
snap.addDispatchTotalUs = getCount("add_phase_dispatch_total_us");
|
|
snap.addDispatchMaxUs = getCount("add_phase_dispatch_max_us");
|
|
snap.addFingerprintTotalUs = getCount("add_phase_fingerprint_total_us");
|
|
snap.addFingerprintMaxUs = getCount("add_phase_fingerprint_max_us");
|
|
snap.addEnqueueTotalUs = getCount("add_phase_enqueue_total_us");
|
|
snap.addEnqueueMaxUs = getCount("add_phase_enqueue_max_us");
|
|
GetStatsRequest statsReq;
|
|
auto statsRes = yams::cli::run_sync(client.getStats(statsReq), 3s);
|
|
const auto* additionalStats = statsRes ? &statsRes.value().additionalStats : nullptr;
|
|
auto getAdditionalUint = [&](std::string_view key) -> uint64_t {
|
|
if (!additionalStats)
|
|
return 0;
|
|
auto it = additionalStats->find(std::string(key));
|
|
if (it == additionalStats->end() || it->second.empty())
|
|
return 0;
|
|
try {
|
|
return static_cast<uint64_t>(std::stoull(it->second));
|
|
} catch (...) {
|
|
return 0;
|
|
}
|
|
};
|
|
snap.writeMaxBatchApplyMs = getAdditionalUint("write_max_batch_apply_ms");
|
|
snap.writeQueueDepth = getAdditionalUint("write_queue_depth");
|
|
snap.writeInFlight = getAdditionalUint("write_in_flight");
|
|
snap.writeQueueCapacity = getAdditionalUint("write_queue_capacity");
|
|
snap.writeQueueDepthMax = getAdditionalUint("write_queue_depth_max");
|
|
snap.writeQueueCapacityRejections = getAdditionalUint("write_queue_capacity_rejections");
|
|
snap.writeQueueForcedOverCapacity = getAdditionalUint("write_queue_forced_over_capacity");
|
|
snap.dbWritePoolMaxObservedWaiting =
|
|
getAdditionalUint(metrics::kDbWritePoolMaxObservedWaiting);
|
|
snap.dbReadPoolMaxObservedWaiting =
|
|
getAdditionalUint(metrics::kDbReadPoolMaxObservedWaiting);
|
|
snap.dbWritePoolTotalWaitMicros = getAdditionalUint(metrics::kDbWritePoolTotalWaitMicros);
|
|
snap.dbReadPoolTotalWaitMicros = getAdditionalUint(metrics::kDbReadPoolTotalWaitMicros);
|
|
snap.dbWritePoolTimeoutCount = getAdditionalUint(metrics::kDbWritePoolTimeoutCount);
|
|
snap.dbReadPoolTimeoutCount = getAdditionalUint(metrics::kDbReadPoolTimeoutCount);
|
|
snap.dbWritePoolFailedAcquisitions =
|
|
getAdditionalUint(metrics::kDbWritePoolFailedAcquisitions);
|
|
snap.dbReadPoolFailedAcquisitions =
|
|
getAdditionalUint(metrics::kDbReadPoolFailedAcquisitions);
|
|
snap.dbWritePoolSlowHolders = getAdditionalUint(metrics::kDbWritePoolSlowHolders);
|
|
snap.dbReadPoolSlowHolders = getAdditionalUint(metrics::kDbReadPoolSlowHolders);
|
|
snap.dbWritePoolMaxHolderMicros = getAdditionalUint(metrics::kDbWritePoolMaxHolderMicros);
|
|
snap.dbReadPoolMaxHolderMicros = getAdditionalUint(metrics::kDbReadPoolMaxHolderMicros);
|
|
snap.writeMaxBatchQueueWaitMs = getAdditionalUint("write_max_batch_queue_wait_ms");
|
|
snap.writeMaxBatchExcessQueueWaitMs =
|
|
getAdditionalUint("write_max_batch_excess_queue_wait_ms");
|
|
snap.postCommitContentIndexCalls =
|
|
getAdditionalUint("post_timing_commit_content_index_calls");
|
|
snap.postCommitContentIndexTotalMs =
|
|
getAdditionalUint("post_timing_commit_content_index_total_ms");
|
|
snap.postCommitContentIndexMaxMs =
|
|
getAdditionalUint("post_timing_commit_content_index_max_ms");
|
|
if (additionalStats) {
|
|
if (auto it = additionalStats->find("write_hot_sources");
|
|
it != additionalStats->end()) {
|
|
snap.writeHotSources = it->second;
|
|
}
|
|
}
|
|
return snap;
|
|
}
|
|
|
|
json toJson() const {
|
|
return json{{"active_connections", activeConnections},
|
|
{"max_connections", maxConnections},
|
|
{"connection_slots_free", connectionSlotsFree},
|
|
{"oldest_connection_age_s", oldestConnectionAge},
|
|
{"forced_close_count", forcedCloseCount},
|
|
{"requests_processed", requestsProcessed},
|
|
{"memory_mb", memoryUsageMb},
|
|
{"cpu_pct", cpuUsagePercent},
|
|
{"rss_bytes", rssBytes},
|
|
{"post_ingest_queued", postIngestQueued},
|
|
{"post_ingest_inflight", postIngestInflight},
|
|
{"worker_active", workerActive},
|
|
{"worker_queued", workerQueued},
|
|
{"deferred_queue_depth", deferredQueueDepth},
|
|
{"pressure_level", pressureLevel},
|
|
{"documents_total", documentsTotal},
|
|
{"search_active", searchActive},
|
|
{"search_queued", searchQueued},
|
|
{"db_write_pool_waiting", dbWritePoolWaiting},
|
|
{"db_read_pool_waiting", dbReadPoolWaiting},
|
|
{"db_write_pool_max_observed_waiting", dbWritePoolMaxObservedWaiting},
|
|
{"db_read_pool_max_observed_waiting", dbReadPoolMaxObservedWaiting},
|
|
{"db_write_pool_total_wait_us", dbWritePoolTotalWaitMicros},
|
|
{"db_read_pool_total_wait_us", dbReadPoolTotalWaitMicros},
|
|
{"db_write_pool_timeouts", dbWritePoolTimeoutCount},
|
|
{"db_read_pool_timeouts", dbReadPoolTimeoutCount},
|
|
{"db_write_pool_failed_acquisitions", dbWritePoolFailedAcquisitions},
|
|
{"db_read_pool_failed_acquisitions", dbReadPoolFailedAcquisitions},
|
|
{"db_write_pool_slow_holders", dbWritePoolSlowHolders},
|
|
{"db_read_pool_slow_holders", dbReadPoolSlowHolders},
|
|
{"db_write_pool_max_holder_us", dbWritePoolMaxHolderMicros},
|
|
{"db_read_pool_max_holder_us", dbReadPoolMaxHolderMicros},
|
|
{"write_queue_depth", writeQueueDepth},
|
|
{"write_queue_capacity", writeQueueCapacity},
|
|
{"write_queue_depth_max", writeQueueDepthMax},
|
|
{"write_queue_capacity_rejections", writeQueueCapacityRejections},
|
|
{"write_queue_forced_over_capacity", writeQueueForcedOverCapacity},
|
|
{"write_in_flight", writeInFlight},
|
|
{"write_max_batch_apply_ms", writeMaxBatchApplyMs},
|
|
{"write_max_batch_queue_wait_ms", writeMaxBatchQueueWaitMs},
|
|
{"write_max_batch_excess_queue_wait_ms", writeMaxBatchExcessQueueWaitMs},
|
|
{"process_fd_count", processFdCount},
|
|
{"process_fd_sockets", processFdSockets},
|
|
{"process_fd_pipes", processFdPipes},
|
|
{"process_fd_files", processFdFiles},
|
|
{"process_fd_devices", processFdDevices},
|
|
{"process_fd_kqueues", processFdKqueues},
|
|
{"process_fd_other", processFdOther},
|
|
{"post_commit_content_index_calls", postCommitContentIndexCalls},
|
|
{"post_commit_content_index_total_ms", postCommitContentIndexTotalMs},
|
|
{"post_commit_content_index_max_ms", postCommitContentIndexMaxMs},
|
|
{"add_dispatch_samples", addDispatchSamples},
|
|
{"add_dispatch_total_us", addDispatchTotalUs},
|
|
{"add_dispatch_max_us", addDispatchMaxUs},
|
|
{"add_fingerprint_total_us", addFingerprintTotalUs},
|
|
{"add_fingerprint_max_us", addFingerprintMaxUs},
|
|
{"add_enqueue_total_us", addEnqueueTotalUs},
|
|
{"add_enqueue_max_us", addEnqueueMaxUs},
|
|
{"client_pool_open_sockets", clientPoolOpenSockets},
|
|
{"client_pool_alive_connections", clientPoolAliveConnections},
|
|
{"client_pool_in_use_connections", clientPoolInUseConnections},
|
|
{"write_hot_sources", writeHotSources},
|
|
{"mux_writer_budget_bytes", muxWriterBudgetBytes},
|
|
{"ipc_pool_size", ipcPoolSize},
|
|
{"io_pool_size", ioPoolSize},
|
|
{"retry_after_ms", retryAfterMs},
|
|
{"metadata_wal_bytes", metadataWalBytes}};
|
|
}
|
|
};
|
|
|
|
ProbeDaemonState captureProbeDaemonState(const fs::path& socketPath,
|
|
std::chrono::milliseconds timeout = 3s) {
|
|
ProbeDaemonState state;
|
|
ClientConfig cfg;
|
|
cfg.socketPath = socketPath;
|
|
cfg.autoStart = false;
|
|
cfg.singleUseConnections = true;
|
|
cfg.requestTimeout = timeout;
|
|
DaemonClient client(cfg);
|
|
|
|
auto st = yams::cli::run_sync(client.status(), timeout);
|
|
if (!st) {
|
|
state.error = st.error().message;
|
|
return state;
|
|
}
|
|
|
|
state.captured = true;
|
|
state.activeConnections = st.value().activeConnections;
|
|
state.maxConnections = st.value().maxConnections;
|
|
state.connectionSlotsFree = st.value().connectionSlotsFree;
|
|
state.pressureLevel = 0;
|
|
auto getCount = [&](std::string_view key) -> uint64_t {
|
|
auto it = st.value().requestCounts.find(std::string(key));
|
|
return (it != st.value().requestCounts.end()) ? it->second : 0ULL;
|
|
};
|
|
if (state.maxConnections == 0) {
|
|
state.maxConnections = static_cast<size_t>(getCount("status_max_connections"));
|
|
}
|
|
if (state.connectionSlotsFree == 0) {
|
|
state.connectionSlotsFree = static_cast<size_t>(getCount("status_connection_slots_free"));
|
|
}
|
|
auto it = st.value().requestCounts.find(std::string(metrics::kPressureLevel));
|
|
if (it != st.value().requestCounts.end()) {
|
|
state.pressureLevel = static_cast<int>(it->second);
|
|
}
|
|
state.retryAfterMs = st.value().retryAfterMs;
|
|
if (state.retryAfterMs == 0) {
|
|
state.retryAfterMs = static_cast<uint32_t>(getCount("status_retry_after_ms"));
|
|
}
|
|
return state;
|
|
}
|
|
|
|
std::string classifyFailureMessage(const std::string& errorMsg);
|
|
|
|
std::string classifyCliProbeBucket(const CliProbeResult& probe) {
|
|
if (probe.success) {
|
|
return "success";
|
|
}
|
|
if (probe.timedOut) {
|
|
return "timeout";
|
|
}
|
|
if (probe.signal > 0) {
|
|
return "signal";
|
|
}
|
|
if (probe.exitCode == 127) {
|
|
return "exec_failed";
|
|
}
|
|
return classifyFailureMessage(!probe.stderrText.empty() ? probe.stderrText : probe.error);
|
|
}
|
|
|
|
std::string classifyCliProbeMode(const CliProbeResult& probe) {
|
|
if (probe.success) {
|
|
return "success";
|
|
}
|
|
|
|
const bool underStress = probe.beforeState.underStress() || probe.afterState.underStress();
|
|
const std::string bucket =
|
|
probe.failureBucket.empty() ? classifyCliProbeBucket(probe) : probe.failureBucket;
|
|
|
|
if (bucket == "exec_failed") {
|
|
return "benchmark_wiring";
|
|
}
|
|
if (bucket == "signal") {
|
|
return "unexpected_signal";
|
|
}
|
|
if (bucket == "protocol") {
|
|
return "protocol_or_cli_bug";
|
|
}
|
|
if (bucket == "connect_refused") {
|
|
return underStress ? "stress_expected" : "ambient_daemon_down";
|
|
}
|
|
if (bucket == "backpressure") {
|
|
return underStress ? "overload_under_load" : "ambient_failure";
|
|
}
|
|
if (bucket == "connection_drop") {
|
|
return underStress ? "stress_expected" : "ambient_failure";
|
|
}
|
|
if (bucket == "timeout") {
|
|
return underStress ? "timeout_under_load" : "ambient_failure";
|
|
}
|
|
return underStress ? "stress_expected" : "unexpected_cli_failure";
|
|
}
|
|
|
|
struct RecoveryCheck {
|
|
bool statusOk{false};
|
|
bool listOk{false};
|
|
std::string statusError;
|
|
std::string listError;
|
|
DaemonSnapshot snapshot;
|
|
|
|
bool ok() const { return statusOk && listOk; }
|
|
|
|
json toJson() const {
|
|
return json{{"status_ok", statusOk},
|
|
{"list_ok", listOk},
|
|
{"status_error", statusError},
|
|
{"list_error", listError},
|
|
{"snapshot", snapshot.toJson()}};
|
|
}
|
|
};
|
|
|
|
RecoveryCheck runRecoveryCheck(const fs::path& socketPath) {
|
|
RecoveryCheck check;
|
|
ClientConfig cfg;
|
|
cfg.socketPath = socketPath;
|
|
cfg.autoStart = false;
|
|
cfg.singleUseConnections = true;
|
|
cfg.requestTimeout = 10s;
|
|
DaemonClient client(cfg);
|
|
|
|
auto statusRes = yams::cli::run_sync(client.status(), 10s);
|
|
if (statusRes) {
|
|
check.statusOk = true;
|
|
check.snapshot = DaemonSnapshot::capture(client);
|
|
} else {
|
|
check.statusError = statusRes.error().message;
|
|
}
|
|
|
|
ListRequest listReq;
|
|
listReq.limit = 10;
|
|
auto listRes = yams::cli::run_sync(client.list(listReq), 10s);
|
|
if (listRes) {
|
|
check.listOk = true;
|
|
} else {
|
|
check.listError = listRes.error().message;
|
|
}
|
|
|
|
return check;
|
|
}
|
|
|
|
void printRecoverySummary(const RecoveryCheck& recovery) {
|
|
std::cout << " Recovery: status=" << (recovery.statusOk ? "ok" : "FAIL")
|
|
<< " list=" << (recovery.listOk ? "ok" : "FAIL") << "\n";
|
|
if (!recovery.statusOk && !recovery.statusError.empty()) {
|
|
std::cout << " status error: " << recovery.statusError << "\n";
|
|
}
|
|
if (!recovery.listOk && !recovery.listError.empty()) {
|
|
std::cout << " list error: " << recovery.listError << "\n";
|
|
}
|
|
}
|
|
|
|
CliProbeResult runObservedCliProbe(const std::string& opType, const fs::path& cliBinary,
|
|
const fs::path& socketPath, const std::vector<std::string>& args,
|
|
std::chrono::milliseconds timeout = 10s) {
|
|
auto beforeState = captureProbeDaemonState(socketPath, std::min(timeout, 1500ms));
|
|
auto probe = runCliProbe(cliBinary, socketPath, args, timeout);
|
|
probe.opType = opType;
|
|
probe.beforeState = beforeState;
|
|
probe.afterState = captureProbeDaemonState(socketPath, std::min(timeout, 1500ms));
|
|
probe.failureBucket = classifyCliProbeBucket(probe);
|
|
probe.failureMode = classifyCliProbeMode(probe);
|
|
return probe;
|
|
}
|
|
|
|
void printCliProbeFailureSummary(const std::map<std::string, int>& failureModes,
|
|
const std::map<std::string, std::string>& failureSamples) {
|
|
if (failureModes.empty()) {
|
|
return;
|
|
}
|
|
std::cout << " CLI failure modes:";
|
|
for (const auto& [mode, count] : failureModes) {
|
|
std::cout << " " << mode << "=" << count;
|
|
}
|
|
std::cout << "\n";
|
|
for (const auto& [mode, sample] : failureSamples) {
|
|
std::cout << " sample[" << mode << "]: " << sample << "\n";
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Time-series sampler (runs in background during benchmark)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class TimeSeriesSampler {
|
|
public:
|
|
struct Sample {
|
|
uint64_t elapsedMs{0};
|
|
DaemonSnapshot snap;
|
|
};
|
|
|
|
~TimeSeriesSampler() { stop(); }
|
|
|
|
void start(DaemonClient& client, std::chrono::milliseconds interval = 500ms,
|
|
fs::path dataDir = {}) {
|
|
stop();
|
|
stop_ = false;
|
|
startTime_ = std::chrono::steady_clock::now();
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
samples_.clear();
|
|
}
|
|
thread_ = std::thread([this, &client, interval, dataDir = std::move(dataDir)]() {
|
|
while (!stop_) {
|
|
Sample sample;
|
|
sample.elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - startTime_)
|
|
.count();
|
|
sample.snap = DaemonSnapshot::capture(client, dataDir);
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
samples_.push_back(sample);
|
|
}
|
|
std::this_thread::sleep_for(interval);
|
|
}
|
|
});
|
|
}
|
|
|
|
void stop() {
|
|
stop_ = true;
|
|
if (thread_.joinable())
|
|
thread_.join();
|
|
}
|
|
|
|
std::vector<Sample> getSamples() const {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
return samples_;
|
|
}
|
|
|
|
private:
|
|
std::atomic<bool> stop_{false};
|
|
std::chrono::steady_clock::time_point startTime_;
|
|
std::thread thread_;
|
|
mutable std::mutex mutex_;
|
|
std::vector<Sample> samples_;
|
|
};
|
|
|
|
struct SlotScalingSummary {
|
|
size_t initialLimit{0};
|
|
size_t peakLimit{0};
|
|
size_t finalLimit{0};
|
|
size_t minFreeSlots{0};
|
|
size_t peakActiveConnections{0};
|
|
double peakUtilization{0.0};
|
|
int growEvents{0};
|
|
int shrinkEvents{0};
|
|
int retryAfterSamples{0};
|
|
};
|
|
|
|
SlotScalingSummary summarizeSlotScaling(const std::vector<TimeSeriesSampler::Sample>& samples) {
|
|
SlotScalingSummary summary;
|
|
if (samples.empty()) {
|
|
return summary;
|
|
}
|
|
|
|
summary.initialLimit = samples.front().snap.maxConnections;
|
|
summary.peakLimit = samples.front().snap.maxConnections;
|
|
summary.finalLimit = samples.back().snap.maxConnections;
|
|
summary.minFreeSlots = samples.front().snap.connectionSlotsFree;
|
|
|
|
size_t previousLimit = samples.front().snap.maxConnections;
|
|
for (const auto& sample : samples) {
|
|
const auto& snap = sample.snap;
|
|
summary.peakLimit = std::max(summary.peakLimit, snap.maxConnections);
|
|
summary.minFreeSlots = std::min(summary.minFreeSlots, snap.connectionSlotsFree);
|
|
summary.peakActiveConnections =
|
|
std::max(summary.peakActiveConnections, snap.activeConnections);
|
|
if (snap.maxConnections > 0) {
|
|
summary.peakUtilization =
|
|
std::max(summary.peakUtilization, static_cast<double>(snap.activeConnections) /
|
|
static_cast<double>(snap.maxConnections));
|
|
}
|
|
if (snap.retryAfterMs > 0) {
|
|
summary.retryAfterSamples++;
|
|
}
|
|
if (snap.maxConnections > previousLimit) {
|
|
summary.growEvents++;
|
|
} else if (snap.maxConnections < previousLimit) {
|
|
summary.shrinkEvents++;
|
|
}
|
|
previousLimit = snap.maxConnections;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
struct TimedLatencySample {
|
|
std::string opType;
|
|
uint64_t wallClockMs{0};
|
|
int64_t latencyUs{0};
|
|
bool success{false};
|
|
};
|
|
|
|
std::optional<int64_t> parseInt64(std::string_view value) {
|
|
try {
|
|
return static_cast<int64_t>(std::stoll(std::string(value)));
|
|
} catch (...) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
|
|
std::optional<int64_t> metricInt(const std::map<std::string, std::string>& stats,
|
|
std::string_view key) {
|
|
const auto it = stats.find(std::string(key));
|
|
if (it == stats.end()) {
|
|
return std::nullopt;
|
|
}
|
|
return parseInt64(it->second);
|
|
}
|
|
|
|
void collectSearchHydrationTelemetry(const SearchResponse& response, ClientResult& result) {
|
|
const auto appendTiming = [&](std::string_view key, std::vector<int64_t>& samples) {
|
|
if (auto value = metricInt(response.searchStats, key)) {
|
|
samples.push_back(*value);
|
|
}
|
|
};
|
|
const auto addCounter = [&](std::string_view key, int64_t& counter) {
|
|
if (auto value = metricInt(response.searchStats, key)) {
|
|
counter = checkedMetricAdd(counter, *value);
|
|
}
|
|
};
|
|
|
|
appendTiming("timing_snippet_hydration_us", result.snippetHydrationUs);
|
|
appendTiming("timing_snippet_lookup_us", result.snippetLookupUs);
|
|
appendTiming("timing_snippet_content_fetch_us", result.snippetContentFetchUs);
|
|
appendTiming("timing_snippet_render_us", result.snippetRenderUs);
|
|
addCounter("snippet_hydration_candidates", result.snippetHydrationCandidates);
|
|
addCounter("snippet_hydration_missing", result.snippetHydrationMissing);
|
|
addCounter("snippet_hydrated_results", result.snippetHydratedResults);
|
|
const auto timeout = response.searchStats.find("snippet_timeout_hit");
|
|
if (timeout != response.searchStats.end() && timeout->second == "true") {
|
|
++result.snippetTimeoutHits;
|
|
}
|
|
}
|
|
|
|
void appendStringTimingStats(std::map<std::string, std::vector<int64_t>>& dest, std::string_view op,
|
|
const std::map<std::string, std::string>& stats, int64_t wallUs) {
|
|
std::optional<int64_t> dispatchTotalUs;
|
|
for (const auto& [key, value] : stats) {
|
|
if (!key.ends_with("_us")) {
|
|
continue;
|
|
}
|
|
auto parsed = parseInt64(value);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
dest[std::string(op) + ":" + key].push_back(*parsed);
|
|
if (key == "phase_dispatch_total_us") {
|
|
dispatchTotalUs = *parsed;
|
|
}
|
|
}
|
|
if (dispatchTotalUs) {
|
|
dest[std::string(op) + ":client_transport_overhead_us"].push_back(
|
|
std::max<int64_t>(0, wallUs - *dispatchTotalUs));
|
|
}
|
|
}
|
|
|
|
void appendStatusTimingStats(std::map<std::string, std::vector<int64_t>>& dest,
|
|
const std::map<std::string, size_t>& stats, int64_t wallUs) {
|
|
auto it = stats.find("phase_dispatch_total_us");
|
|
if (it == stats.end()) {
|
|
return;
|
|
}
|
|
const auto dispatchTotalUs = static_cast<int64_t>(it->second);
|
|
dest["status:phase_dispatch_total_us"].push_back(dispatchTotalUs);
|
|
dest["status:client_transport_overhead_us"].push_back(
|
|
std::max<int64_t>(0, wallUs - dispatchTotalUs));
|
|
}
|
|
|
|
json summarizeTimingStats(std::map<std::string, std::vector<int64_t>> stats) {
|
|
json out = json::object();
|
|
for (auto& [key, values] : stats) {
|
|
out[key] = PercentileStats::compute(values).toJson();
|
|
}
|
|
return out;
|
|
}
|
|
|
|
struct ResourcePeaks {
|
|
double peakMemoryMb{0.0};
|
|
double peakCpuPercent{0.0};
|
|
uint64_t peakRssBytes{0};
|
|
size_t peakActiveConnections{0};
|
|
size_t peakMaxConnections{0};
|
|
uint64_t peakPostIngestQueued{0};
|
|
uint64_t peakPostIngestInflight{0};
|
|
uint64_t peakSearchActive{0};
|
|
uint64_t peakSearchQueued{0};
|
|
uint64_t peakDbWritePoolWaiting{0};
|
|
uint64_t peakDbReadPoolWaiting{0};
|
|
uint64_t peakDbWritePoolMaxObservedWaiting{0};
|
|
uint64_t peakDbReadPoolMaxObservedWaiting{0};
|
|
uint64_t peakDbWritePoolTotalWaitMicros{0};
|
|
uint64_t peakDbReadPoolTotalWaitMicros{0};
|
|
uint64_t peakDbWritePoolTimeoutCount{0};
|
|
uint64_t peakDbReadPoolTimeoutCount{0};
|
|
uint64_t peakDbWritePoolFailedAcquisitions{0};
|
|
uint64_t peakDbReadPoolFailedAcquisitions{0};
|
|
uint64_t peakDbWritePoolSlowHolders{0};
|
|
uint64_t peakDbReadPoolSlowHolders{0};
|
|
uint64_t peakDbWritePoolMaxHolderMicros{0};
|
|
uint64_t peakDbReadPoolMaxHolderMicros{0};
|
|
uint64_t peakMuxWriterBudgetBytes{0};
|
|
uint64_t peakWriteQueueDepth{0};
|
|
uint64_t writeQueueCapacity{0};
|
|
uint64_t peakWriteQueueDepthMax{0};
|
|
uint64_t peakWriteQueueCapacityRejections{0};
|
|
uint64_t peakWriteQueueForcedOverCapacity{0};
|
|
uint64_t peakWriteInFlight{0};
|
|
uint64_t peakWriteMaxBatchApplyMs{0};
|
|
uint64_t peakWriteMaxBatchQueueWaitMs{0};
|
|
uint64_t peakWriteMaxBatchExcessQueueWaitMs{0};
|
|
uint64_t peakProcessFdCount{0};
|
|
uint64_t peakProcessFdSockets{0};
|
|
uint64_t peakProcessFdPipes{0};
|
|
uint64_t peakProcessFdFiles{0};
|
|
uint64_t peakProcessFdDevices{0};
|
|
uint64_t peakProcessFdKqueues{0};
|
|
uint64_t peakProcessFdOther{0};
|
|
uint64_t peakPostCommitContentIndexCalls{0};
|
|
uint64_t peakPostCommitContentIndexTotalMs{0};
|
|
uint64_t peakPostCommitContentIndexMaxMs{0};
|
|
uint64_t peakClientPoolOpenSockets{0};
|
|
uint64_t peakClientPoolAliveConnections{0};
|
|
uint64_t peakClientPoolInUseConnections{0};
|
|
uint64_t peakMetadataWalBytes{0};
|
|
std::string lastWriteHotSources;
|
|
uint32_t maxPressureLevel{0};
|
|
|
|
json toJson() const {
|
|
return json{
|
|
{"peak_memory_mb", peakMemoryMb},
|
|
{"peak_cpu_pct", peakCpuPercent},
|
|
{"peak_rss_bytes", peakRssBytes},
|
|
{"peak_active_connections", peakActiveConnections},
|
|
{"peak_max_connections", peakMaxConnections},
|
|
{"peak_post_ingest_queued", peakPostIngestQueued},
|
|
{"peak_post_ingest_inflight", peakPostIngestInflight},
|
|
{"peak_search_active", peakSearchActive},
|
|
{"peak_search_queued", peakSearchQueued},
|
|
{"peak_db_write_pool_waiting", peakDbWritePoolWaiting},
|
|
{"peak_db_read_pool_waiting", peakDbReadPoolWaiting},
|
|
{"peak_db_write_pool_max_observed_waiting", peakDbWritePoolMaxObservedWaiting},
|
|
{"peak_db_read_pool_max_observed_waiting", peakDbReadPoolMaxObservedWaiting},
|
|
{"peak_db_write_pool_total_wait_us", peakDbWritePoolTotalWaitMicros},
|
|
{"peak_db_read_pool_total_wait_us", peakDbReadPoolTotalWaitMicros},
|
|
{"peak_db_write_pool_timeouts", peakDbWritePoolTimeoutCount},
|
|
{"peak_db_read_pool_timeouts", peakDbReadPoolTimeoutCount},
|
|
{"peak_db_write_pool_failed_acquisitions", peakDbWritePoolFailedAcquisitions},
|
|
{"peak_db_read_pool_failed_acquisitions", peakDbReadPoolFailedAcquisitions},
|
|
{"peak_db_write_pool_slow_holders", peakDbWritePoolSlowHolders},
|
|
{"peak_db_read_pool_slow_holders", peakDbReadPoolSlowHolders},
|
|
{"peak_db_write_pool_max_holder_us", peakDbWritePoolMaxHolderMicros},
|
|
{"peak_db_read_pool_max_holder_us", peakDbReadPoolMaxHolderMicros},
|
|
{"peak_mux_writer_budget_bytes", peakMuxWriterBudgetBytes},
|
|
{"peak_write_queue_depth", peakWriteQueueDepth},
|
|
{"write_queue_capacity", writeQueueCapacity},
|
|
{"peak_write_queue_depth_max", peakWriteQueueDepthMax},
|
|
{"peak_write_queue_capacity_rejections", peakWriteQueueCapacityRejections},
|
|
{"peak_write_queue_forced_over_capacity", peakWriteQueueForcedOverCapacity},
|
|
{"peak_write_in_flight", peakWriteInFlight},
|
|
{"peak_write_max_batch_apply_ms", peakWriteMaxBatchApplyMs},
|
|
{"peak_write_max_batch_queue_wait_ms", peakWriteMaxBatchQueueWaitMs},
|
|
{"peak_write_max_batch_excess_queue_wait_ms", peakWriteMaxBatchExcessQueueWaitMs},
|
|
{"peak_process_fd_count", peakProcessFdCount},
|
|
{"peak_process_fd_sockets", peakProcessFdSockets},
|
|
{"peak_process_fd_pipes", peakProcessFdPipes},
|
|
{"peak_process_fd_files", peakProcessFdFiles},
|
|
{"peak_process_fd_devices", peakProcessFdDevices},
|
|
{"peak_process_fd_kqueues", peakProcessFdKqueues},
|
|
{"peak_process_fd_other", peakProcessFdOther},
|
|
{"peak_post_commit_content_index_calls", peakPostCommitContentIndexCalls},
|
|
{"peak_post_commit_content_index_total_ms", peakPostCommitContentIndexTotalMs},
|
|
{"peak_post_commit_content_index_max_ms", peakPostCommitContentIndexMaxMs},
|
|
{"peak_client_pool_open_sockets", peakClientPoolOpenSockets},
|
|
{"peak_client_pool_alive_connections", peakClientPoolAliveConnections},
|
|
{"peak_client_pool_in_use_connections", peakClientPoolInUseConnections},
|
|
{"peak_metadata_wal_bytes", peakMetadataWalBytes},
|
|
{"last_write_hot_sources", lastWriteHotSources},
|
|
{"max_pressure_level", maxPressureLevel}};
|
|
}
|
|
};
|
|
|
|
ResourcePeaks summarizeResourcePeaks(const std::vector<TimeSeriesSampler::Sample>& samples) {
|
|
ResourcePeaks peaks;
|
|
for (const auto& sample : samples) {
|
|
const auto& snap = sample.snap;
|
|
peaks.peakMemoryMb = std::max(peaks.peakMemoryMb, snap.memoryUsageMb);
|
|
peaks.peakCpuPercent = std::max(peaks.peakCpuPercent, snap.cpuUsagePercent);
|
|
peaks.peakRssBytes = std::max(peaks.peakRssBytes, snap.rssBytes);
|
|
peaks.peakActiveConnections = std::max(peaks.peakActiveConnections, snap.activeConnections);
|
|
peaks.peakMaxConnections = std::max(peaks.peakMaxConnections, snap.maxConnections);
|
|
peaks.peakPostIngestQueued = std::max(peaks.peakPostIngestQueued, snap.postIngestQueued);
|
|
peaks.peakPostIngestInflight =
|
|
std::max(peaks.peakPostIngestInflight, snap.postIngestInflight);
|
|
peaks.peakSearchActive = std::max(peaks.peakSearchActive, snap.searchActive);
|
|
peaks.peakSearchQueued = std::max(peaks.peakSearchQueued, snap.searchQueued);
|
|
peaks.peakDbWritePoolWaiting =
|
|
std::max(peaks.peakDbWritePoolWaiting, snap.dbWritePoolWaiting);
|
|
peaks.peakDbReadPoolWaiting = std::max(peaks.peakDbReadPoolWaiting, snap.dbReadPoolWaiting);
|
|
peaks.peakDbWritePoolMaxObservedWaiting =
|
|
std::max(peaks.peakDbWritePoolMaxObservedWaiting, snap.dbWritePoolMaxObservedWaiting);
|
|
peaks.peakDbReadPoolMaxObservedWaiting =
|
|
std::max(peaks.peakDbReadPoolMaxObservedWaiting, snap.dbReadPoolMaxObservedWaiting);
|
|
peaks.peakDbWritePoolTotalWaitMicros =
|
|
std::max(peaks.peakDbWritePoolTotalWaitMicros, snap.dbWritePoolTotalWaitMicros);
|
|
peaks.peakDbReadPoolTotalWaitMicros =
|
|
std::max(peaks.peakDbReadPoolTotalWaitMicros, snap.dbReadPoolTotalWaitMicros);
|
|
peaks.peakDbWritePoolTimeoutCount =
|
|
std::max(peaks.peakDbWritePoolTimeoutCount, snap.dbWritePoolTimeoutCount);
|
|
peaks.peakDbReadPoolTimeoutCount =
|
|
std::max(peaks.peakDbReadPoolTimeoutCount, snap.dbReadPoolTimeoutCount);
|
|
peaks.peakDbWritePoolFailedAcquisitions =
|
|
std::max(peaks.peakDbWritePoolFailedAcquisitions, snap.dbWritePoolFailedAcquisitions);
|
|
peaks.peakDbReadPoolFailedAcquisitions =
|
|
std::max(peaks.peakDbReadPoolFailedAcquisitions, snap.dbReadPoolFailedAcquisitions);
|
|
peaks.peakDbWritePoolSlowHolders =
|
|
std::max(peaks.peakDbWritePoolSlowHolders, snap.dbWritePoolSlowHolders);
|
|
peaks.peakDbReadPoolSlowHolders =
|
|
std::max(peaks.peakDbReadPoolSlowHolders, snap.dbReadPoolSlowHolders);
|
|
peaks.peakDbWritePoolMaxHolderMicros =
|
|
std::max(peaks.peakDbWritePoolMaxHolderMicros, snap.dbWritePoolMaxHolderMicros);
|
|
peaks.peakDbReadPoolMaxHolderMicros =
|
|
std::max(peaks.peakDbReadPoolMaxHolderMicros, snap.dbReadPoolMaxHolderMicros);
|
|
peaks.peakMuxWriterBudgetBytes =
|
|
std::max(peaks.peakMuxWriterBudgetBytes, snap.muxWriterBudgetBytes);
|
|
peaks.peakWriteQueueDepth = std::max(peaks.peakWriteQueueDepth, snap.writeQueueDepth);
|
|
peaks.writeQueueCapacity = std::max(peaks.writeQueueCapacity, snap.writeQueueCapacity);
|
|
peaks.peakWriteQueueDepthMax =
|
|
std::max(peaks.peakWriteQueueDepthMax, snap.writeQueueDepthMax);
|
|
peaks.peakWriteQueueCapacityRejections =
|
|
std::max(peaks.peakWriteQueueCapacityRejections, snap.writeQueueCapacityRejections);
|
|
peaks.peakWriteQueueForcedOverCapacity =
|
|
std::max(peaks.peakWriteQueueForcedOverCapacity, snap.writeQueueForcedOverCapacity);
|
|
peaks.peakWriteInFlight = std::max(peaks.peakWriteInFlight, snap.writeInFlight);
|
|
peaks.peakWriteMaxBatchApplyMs =
|
|
std::max(peaks.peakWriteMaxBatchApplyMs, snap.writeMaxBatchApplyMs);
|
|
peaks.peakWriteMaxBatchQueueWaitMs =
|
|
std::max(peaks.peakWriteMaxBatchQueueWaitMs, snap.writeMaxBatchQueueWaitMs);
|
|
peaks.peakWriteMaxBatchExcessQueueWaitMs =
|
|
std::max(peaks.peakWriteMaxBatchExcessQueueWaitMs, snap.writeMaxBatchExcessQueueWaitMs);
|
|
peaks.peakProcessFdCount = std::max(peaks.peakProcessFdCount, snap.processFdCount);
|
|
peaks.peakProcessFdSockets = std::max(peaks.peakProcessFdSockets, snap.processFdSockets);
|
|
peaks.peakProcessFdPipes = std::max(peaks.peakProcessFdPipes, snap.processFdPipes);
|
|
peaks.peakProcessFdFiles = std::max(peaks.peakProcessFdFiles, snap.processFdFiles);
|
|
peaks.peakProcessFdDevices = std::max(peaks.peakProcessFdDevices, snap.processFdDevices);
|
|
peaks.peakProcessFdKqueues = std::max(peaks.peakProcessFdKqueues, snap.processFdKqueues);
|
|
peaks.peakProcessFdOther = std::max(peaks.peakProcessFdOther, snap.processFdOther);
|
|
peaks.peakPostCommitContentIndexCalls =
|
|
std::max(peaks.peakPostCommitContentIndexCalls, snap.postCommitContentIndexCalls);
|
|
peaks.peakPostCommitContentIndexTotalMs =
|
|
std::max(peaks.peakPostCommitContentIndexTotalMs, snap.postCommitContentIndexTotalMs);
|
|
peaks.peakPostCommitContentIndexMaxMs =
|
|
std::max(peaks.peakPostCommitContentIndexMaxMs, snap.postCommitContentIndexMaxMs);
|
|
peaks.peakClientPoolOpenSockets =
|
|
std::max(peaks.peakClientPoolOpenSockets, snap.clientPoolOpenSockets);
|
|
peaks.peakClientPoolAliveConnections =
|
|
std::max(peaks.peakClientPoolAliveConnections, snap.clientPoolAliveConnections);
|
|
peaks.peakClientPoolInUseConnections =
|
|
std::max(peaks.peakClientPoolInUseConnections, snap.clientPoolInUseConnections);
|
|
peaks.peakMetadataWalBytes = std::max(peaks.peakMetadataWalBytes, snap.metadataWalBytes);
|
|
if (!snap.writeHotSources.empty()) {
|
|
peaks.lastWriteHotSources = snap.writeHotSources;
|
|
}
|
|
peaks.maxPressureLevel = std::max(peaks.maxPressureLevel,
|
|
static_cast<uint32_t>(std::max(0, snap.pressureLevel)));
|
|
}
|
|
return peaks;
|
|
}
|
|
|
|
json summarizeLatencyHotspots(const std::vector<TimedLatencySample>& samples,
|
|
uint64_t windowMs = 500) {
|
|
json out = json{{"window_ms", windowMs},
|
|
{"slowest_ops", json::array()},
|
|
{"worst_window", json::object()},
|
|
{"per_op_p95_rank", json::array()}};
|
|
if (samples.empty()) {
|
|
return out;
|
|
}
|
|
|
|
auto sorted = samples;
|
|
std::sort(sorted.begin(), sorted.end(),
|
|
[](const auto& a, const auto& b) { return a.latencyUs > b.latencyUs; });
|
|
for (size_t i = 0; i < std::min<size_t>(5, sorted.size()); ++i) {
|
|
out["slowest_ops"].push_back(json{{"op_type", sorted[i].opType},
|
|
{"wall_clock_ms", sorted[i].wallClockMs},
|
|
{"latency_us", sorted[i].latencyUs},
|
|
{"success", sorted[i].success}});
|
|
}
|
|
|
|
std::unordered_map<std::string, std::vector<int64_t>> byOp;
|
|
for (const auto& sample : samples) {
|
|
if (sample.success) {
|
|
pushMetricSample(byOp[sample.opType], sample.latencyUs);
|
|
}
|
|
}
|
|
std::vector<std::pair<std::string, PercentileStats>> ranked;
|
|
ranked.reserve(byOp.size());
|
|
for (auto& [op, values] : byOp) {
|
|
ranked.emplace_back(op, PercentileStats::compute(values));
|
|
}
|
|
std::sort(ranked.begin(), ranked.end(),
|
|
[](const auto& a, const auto& b) { return a.second.p95 > b.second.p95; });
|
|
for (const auto& [op, stats] : ranked) {
|
|
out["per_op_p95_rank"].push_back(json{{"op_type", op},
|
|
{"p95_us", stats.p95},
|
|
{"mean_us", stats.mean},
|
|
{"count", stats.count}});
|
|
}
|
|
|
|
size_t left = 0;
|
|
int64_t currentSumUs = 0;
|
|
size_t bestCount = 0;
|
|
int64_t bestSumUs = 0;
|
|
uint64_t bestStartMs = static_cast<uint64_t>(
|
|
clampUnsignedMetricSample(samples.front().wallClockMs, kMaxBenchmarkWallMs));
|
|
uint64_t bestEndMs = bestStartMs;
|
|
auto byTime = samples;
|
|
std::sort(byTime.begin(), byTime.end(),
|
|
[](const auto& a, const auto& b) { return a.wallClockMs < b.wallClockMs; });
|
|
const uint64_t boundedWindowMs =
|
|
std::max<uint64_t>(1, std::min<uint64_t>(windowMs, kMaxBenchmarkWallMs));
|
|
for (size_t right = 0; right < byTime.size(); ++right) {
|
|
const auto rightLatency = clampMetricSample(byTime[right].latencyUs);
|
|
currentSumUs = checkedMetricAdd(currentSumUs, rightLatency);
|
|
auto rightWallMs = static_cast<uint64_t>(
|
|
clampUnsignedMetricSample(byTime[right].wallClockMs, kMaxBenchmarkWallMs));
|
|
auto leftWallMs = static_cast<uint64_t>(
|
|
clampUnsignedMetricSample(byTime[left].wallClockMs, kMaxBenchmarkWallMs));
|
|
while (rightWallMs >= leftWallMs && rightWallMs - leftWallMs > boundedWindowMs) {
|
|
currentSumUs =
|
|
std::max<int64_t>(0, currentSumUs - clampMetricSample(byTime[left].latencyUs));
|
|
++left;
|
|
leftWallMs = static_cast<uint64_t>(
|
|
clampUnsignedMetricSample(byTime[left].wallClockMs, kMaxBenchmarkWallMs));
|
|
}
|
|
const size_t count = right - left + 1;
|
|
if (count > 0 &&
|
|
(currentSumUs > bestSumUs || (currentSumUs == bestSumUs && count > bestCount))) {
|
|
bestSumUs = currentSumUs;
|
|
bestCount = count;
|
|
bestStartMs = leftWallMs;
|
|
bestEndMs = rightWallMs;
|
|
}
|
|
}
|
|
out["worst_window"] = json{
|
|
{"start_ms", bestStartMs},
|
|
{"end_ms", bestEndMs},
|
|
{"sample_count", bestCount},
|
|
{"latency_sum_us", bestSumUs},
|
|
{"avg_latency_us",
|
|
bestCount > 0 ? static_cast<double>(bestSumUs) / static_cast<double>(bestCount) : 0.0}};
|
|
return out;
|
|
}
|
|
|
|
class HeldSocketConnections {
|
|
public:
|
|
bool open(const fs::path& socketPath, int count) {
|
|
lastError_.clear();
|
|
lastFailedIndex_ = -1;
|
|
openedCount_ = 0;
|
|
sockets_.clear();
|
|
sockets_.reserve(static_cast<size_t>(std::max(0, count)));
|
|
|
|
boost::system::error_code ec;
|
|
for (int i = 0; i < count; ++i) {
|
|
auto sock = std::make_unique<boost::asio::local::stream_protocol::socket>(io_);
|
|
sock->connect(boost::asio::local::stream_protocol::endpoint(socketPath.string()), ec);
|
|
if (ec) {
|
|
lastFailedIndex_ = i;
|
|
openedCount_ = static_cast<int>(sockets_.size());
|
|
lastError_ = ec.message();
|
|
closeAll();
|
|
return false;
|
|
}
|
|
sockets_.push_back(std::move(sock));
|
|
openedCount_ = static_cast<int>(sockets_.size());
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void closeAll() {
|
|
for (auto& sock : sockets_) {
|
|
if (!sock) {
|
|
continue;
|
|
}
|
|
boost::system::error_code ec;
|
|
sock->close(ec);
|
|
}
|
|
sockets_.clear();
|
|
}
|
|
|
|
~HeldSocketConnections() { closeAll(); }
|
|
|
|
[[nodiscard]] const std::string& lastError() const noexcept { return lastError_; }
|
|
|
|
[[nodiscard]] int lastFailedIndex() const noexcept { return lastFailedIndex_; }
|
|
|
|
[[nodiscard]] int openedCount() const noexcept { return openedCount_; }
|
|
|
|
private:
|
|
boost::asio::io_context io_;
|
|
std::vector<std::unique_ptr<boost::asio::local::stream_protocol::socket>> sockets_;
|
|
std::string lastError_;
|
|
int lastFailedIndex_{-1};
|
|
int openedCount_{0};
|
|
};
|
|
|
|
class ScopedConnectionSlotOverrides {
|
|
public:
|
|
ScopedConnectionSlotOverrides(uint32_t minSlots, uint32_t maxSlots, uint32_t scaleStep) {
|
|
TuneAdvisor::setConnectionSlotsMin(minSlots);
|
|
TuneAdvisor::setConnectionSlotsMax(maxSlots);
|
|
TuneAdvisor::setConnectionSlotsScaleStep(scaleStep);
|
|
}
|
|
|
|
~ScopedConnectionSlotOverrides() {
|
|
TuneAdvisor::setConnectionSlotsMin(0);
|
|
TuneAdvisor::setConnectionSlotsMax(0);
|
|
TuneAdvisor::setConnectionSlotsScaleStep(0);
|
|
}
|
|
|
|
ScopedConnectionSlotOverrides(const ScopedConnectionSlotOverrides&) = delete;
|
|
ScopedConnectionSlotOverrides& operator=(const ScopedConnectionSlotOverrides&) = delete;
|
|
};
|
|
|
|
bool waitForSlotLimit(DaemonClient& client, size_t targetLimit, std::chrono::milliseconds timeout,
|
|
bool requireAtLeast) {
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
const auto snap = DaemonSnapshot::capture(client);
|
|
if (requireAtLeast) {
|
|
if (snap.maxConnections >= targetLimit) {
|
|
return true;
|
|
}
|
|
} else if (snap.maxConnections <= targetLimit) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(250ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool waitForActiveConnectionsAtMost(DaemonClient& client, size_t targetActive,
|
|
std::chrono::milliseconds timeout) {
|
|
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
const auto snap = DaemonSnapshot::capture(client);
|
|
if (snap.activeConnections <= targetActive) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(250ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Document generator
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
std::string generateDocument(int clientId, int docIndex, size_t targetBytes) {
|
|
std::ostringstream oss;
|
|
oss << "# Client " << clientId << " Document " << docIndex << "\n\n";
|
|
oss << "Created at: " << std::chrono::system_clock::now().time_since_epoch().count() << "\n\n";
|
|
|
|
// Fill with pseudo-realistic text to reach target size
|
|
static const char* paragraphs[] = {
|
|
"The system architecture follows a layered design pattern with clear separation "
|
|
"of concerns between the daemon core, service layer, and client interfaces.\n\n",
|
|
"Performance tuning parameters are exposed through TuneAdvisor knobs allowing "
|
|
"runtime adjustment of thread pool sizes, queue capacities, and concurrency limits.\n\n",
|
|
"The ingestion pipeline processes documents through extraction, indexing, knowledge "
|
|
"graph construction, symbol analysis, entity recognition, and embedding stages.\n\n",
|
|
"Resource governance monitors memory pressure, CPU utilization, and I/O throughput "
|
|
"to dynamically scale worker thread counts and admission control thresholds.\n\n",
|
|
"The IPC transport uses Unix domain sockets with a binary framing protocol supporting "
|
|
"multiplexed request/response streams with backpressure signaling.\n\n",
|
|
};
|
|
constexpr int numParagraphs = 5;
|
|
|
|
int paraIdx = 0;
|
|
while (oss.tellp() < static_cast<std::streamoff>(targetBytes)) {
|
|
oss << paragraphs[paraIdx % numParagraphs];
|
|
paraIdx++;
|
|
}
|
|
|
|
auto result = oss.str();
|
|
if (result.size() > targetBytes)
|
|
result.resize(targetBytes);
|
|
return result;
|
|
}
|
|
|
|
using yams::bench::isoTimestamp;
|
|
|
|
std::string toLowerCopy(std::string value) {
|
|
std::transform(value.begin(), value.end(), value.begin(),
|
|
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
|
return value;
|
|
}
|
|
|
|
bool containsInsensitive(const std::string& haystackLower, std::string_view needle) {
|
|
return haystackLower.find(std::string(needle)) != std::string::npos;
|
|
}
|
|
|
|
std::string classifyFailureMessage(const std::string& errorMsg) {
|
|
if (errorMsg.empty()) {
|
|
return "unknown";
|
|
}
|
|
|
|
const std::string lower = toLowerCopy(errorMsg);
|
|
|
|
if (containsInsensitive(lower, "document content not found")) {
|
|
return "content_not_found";
|
|
}
|
|
if (containsInsensitive(lower, "document not found") ||
|
|
containsInsensitive(lower, "no documents found matching hash prefix")) {
|
|
return "document_not_found";
|
|
}
|
|
|
|
if (containsInsensitive(lower, "timed out") || containsInsensitive(lower, "timeout")) {
|
|
return "timeout";
|
|
}
|
|
if (containsInsensitive(lower, "retry after") || containsInsensitive(lower, "retry in ") ||
|
|
containsInsensitive(lower, "server busy") ||
|
|
containsInsensitive(lower, "resource exhausted") ||
|
|
containsInsensitive(lower, "backpressure")) {
|
|
return "backpressure";
|
|
}
|
|
if (containsInsensitive(lower, "connection refused") ||
|
|
containsInsensitive(lower, "no such file or directory")) {
|
|
return "connect_refused";
|
|
}
|
|
if (containsInsensitive(lower, "broken pipe") || containsInsensitive(lower, "eof") ||
|
|
containsInsensitive(lower, "connection reset")) {
|
|
return "connection_drop";
|
|
}
|
|
if (containsInsensitive(lower, "cancel")) {
|
|
return "cancelled";
|
|
}
|
|
if (containsInsensitive(lower, "database is locked") ||
|
|
containsInsensitive(lower, "sql_busy")) {
|
|
return "db_locked";
|
|
}
|
|
if (containsInsensitive(lower, "queue") &&
|
|
(containsInsensitive(lower, "full") || containsInsensitive(lower, "overflow"))) {
|
|
return "queue_full";
|
|
}
|
|
if (containsInsensitive(lower, "parse") || containsInsensitive(lower, "protocol") ||
|
|
containsInsensitive(lower, "invalid response")) {
|
|
return "protocol";
|
|
}
|
|
|
|
return "other";
|
|
}
|
|
|
|
bool isDocumentMissingFailure(const std::string& errorMsg) {
|
|
if (errorMsg.empty()) {
|
|
return false;
|
|
}
|
|
const std::string lower = toLowerCopy(errorMsg);
|
|
return containsInsensitive(lower, "document not found") ||
|
|
containsInsensitive(lower, "document content not found") ||
|
|
containsInsensitive(lower, "no documents found matching hash prefix");
|
|
}
|
|
|
|
bool isHotspotWindowEligible(int ops, int totalClients) {
|
|
return ops >= std::max(10, totalClients);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Wait for queue drain
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
struct DrainObservation {
|
|
bool drained{false};
|
|
uint64_t elapsedMs{0};
|
|
int polls{0};
|
|
int stablePolls{0};
|
|
uint64_t minimumDocuments{0};
|
|
uint64_t documentsBelowMinimumPolls{0};
|
|
uint64_t postIngestQueuedPolls{0};
|
|
uint64_t postIngestInflightPolls{0};
|
|
uint64_t workerActivePolls{0};
|
|
uint64_t workerQueuedPolls{0};
|
|
uint64_t deferredQueuePolls{0};
|
|
uint64_t coordinatedWritePolls{0};
|
|
uint64_t peakPostIngestQueued{0};
|
|
uint64_t peakPostIngestInflight{0};
|
|
uint64_t peakWorkerActive{0};
|
|
uint64_t peakWorkerQueued{0};
|
|
uint64_t peakDeferredQueueDepth{0};
|
|
double peakCpuPercent{0.0};
|
|
double peakMemoryMb{0.0};
|
|
uint32_t maxPressureLevel{0};
|
|
DaemonSnapshot finalSnapshot;
|
|
|
|
json toJson() const {
|
|
return json{{"drained", drained},
|
|
{"elapsed_ms", elapsedMs},
|
|
{"polls", polls},
|
|
{"stable_polls", stablePolls},
|
|
{"minimum_documents", minimumDocuments},
|
|
{"blocker_polls",
|
|
{{"documents_below_minimum", documentsBelowMinimumPolls},
|
|
{"post_ingest_queued", postIngestQueuedPolls},
|
|
{"post_ingest_inflight", postIngestInflightPolls},
|
|
{"worker_active", workerActivePolls},
|
|
{"worker_queued", workerQueuedPolls},
|
|
{"deferred_queue", deferredQueuePolls},
|
|
{"coordinated_write", coordinatedWritePolls}}},
|
|
{"peak_post_ingest_queued", peakPostIngestQueued},
|
|
{"peak_post_ingest_inflight", peakPostIngestInflight},
|
|
{"peak_worker_active", peakWorkerActive},
|
|
{"peak_worker_queued", peakWorkerQueued},
|
|
{"peak_deferred_queue_depth", peakDeferredQueueDepth},
|
|
{"peak_cpu_pct", peakCpuPercent},
|
|
{"peak_memory_mb", peakMemoryMb},
|
|
{"max_pressure_level", maxPressureLevel},
|
|
{"final_snapshot", finalSnapshot.toJson()}};
|
|
}
|
|
};
|
|
|
|
struct IdleBaselineObservation {
|
|
uint64_t durationMs{0};
|
|
int samples{0};
|
|
double meanCpuPercent{0.0};
|
|
double peakCpuPercent{0.0};
|
|
double meanMemoryMb{0.0};
|
|
double peakMemoryMb{0.0};
|
|
uint64_t maxPostIngestQueued{0};
|
|
uint64_t maxPostIngestInflight{0};
|
|
size_t maxActiveConnections{0};
|
|
uint32_t maxPressureLevel{0};
|
|
|
|
json toJson() const {
|
|
return json{{"duration_ms", durationMs},
|
|
{"samples", samples},
|
|
{"mean_cpu_pct", meanCpuPercent},
|
|
{"peak_cpu_pct", peakCpuPercent},
|
|
{"mean_memory_mb", meanMemoryMb},
|
|
{"peak_memory_mb", peakMemoryMb},
|
|
{"max_post_ingest_queued", maxPostIngestQueued},
|
|
{"max_post_ingest_inflight", maxPostIngestInflight},
|
|
{"max_active_connections", maxActiveConnections},
|
|
{"max_pressure_level", maxPressureLevel}};
|
|
}
|
|
};
|
|
|
|
struct IdleWakeProbeObservation {
|
|
bool enabled{false};
|
|
bool attempted{false};
|
|
bool addCompleted{false};
|
|
bool addSucceeded{false};
|
|
bool workVisible{false};
|
|
uint64_t addLatencyUs{0};
|
|
uint64_t workVisibleUs{0};
|
|
std::string addError;
|
|
DaemonSnapshot beforeSnapshot;
|
|
DaemonSnapshot visibleSnapshot;
|
|
DrainObservation preProbeDrain;
|
|
IdleBaselineObservation idleBaseline;
|
|
DrainObservation postProbeDrain;
|
|
|
|
json toJson() const {
|
|
return json{{"enabled", enabled},
|
|
{"attempted", attempted},
|
|
{"add_completed", addCompleted},
|
|
{"add_succeeded", addSucceeded},
|
|
{"work_visible", workVisible},
|
|
{"add_latency_us", addLatencyUs},
|
|
{"work_visible_us", workVisibleUs},
|
|
{"add_error", addError},
|
|
{"before_snapshot", beforeSnapshot.toJson()},
|
|
{"visible_snapshot", visibleSnapshot.toJson()},
|
|
{"pre_probe_drain", preProbeDrain.toJson()},
|
|
{"idle_baseline", idleBaseline.toJson()},
|
|
{"post_probe_drain", postProbeDrain.toJson()}};
|
|
}
|
|
};
|
|
|
|
bool writePipelineDrained(const DaemonSnapshot& snapshot, uint64_t minimumDocuments = 0) {
|
|
return snapshot.postIngestQueued == 0 && snapshot.postIngestInflight == 0 &&
|
|
snapshot.workerActive == 0 && snapshot.workerQueued == 0 &&
|
|
snapshot.deferredQueueDepth == 0 && snapshot.writeQueueDepth == 0 &&
|
|
snapshot.writeInFlight == 0 && snapshot.documentsTotal >= minimumDocuments;
|
|
}
|
|
|
|
TEST_CASE("Drain contract includes post-ingest and coordinated writes",
|
|
"[benchmark-contract][drain]") {
|
|
CHECK(backgroundWorkerActive(0) == 0);
|
|
CHECK(backgroundWorkerActive(1) == 0);
|
|
CHECK(backgroundWorkerActive(3) == 2);
|
|
|
|
DaemonSnapshot snapshot;
|
|
CHECK(writePipelineDrained(snapshot));
|
|
CHECK(writePipelineDrained(snapshot, 0));
|
|
|
|
snapshot.postIngestQueued = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.postIngestQueued = 0;
|
|
|
|
snapshot.postIngestInflight = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.postIngestInflight = 0;
|
|
|
|
snapshot.writeQueueDepth = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.writeQueueDepth = 0;
|
|
|
|
snapshot.writeInFlight = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.writeInFlight = 0;
|
|
|
|
snapshot.workerActive = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.workerActive = 0;
|
|
|
|
snapshot.workerQueued = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.workerQueued = 0;
|
|
|
|
snapshot.deferredQueueDepth = 1;
|
|
CHECK_FALSE(writePipelineDrained(snapshot));
|
|
snapshot.deferredQueueDepth = 0;
|
|
|
|
snapshot.documentsTotal = 41;
|
|
CHECK_FALSE(writePipelineDrained(snapshot, 42));
|
|
snapshot.documentsTotal = 42;
|
|
CHECK(writePipelineDrained(snapshot, 42));
|
|
}
|
|
|
|
DrainObservation waitForDrainObservation(DaemonClient& client, std::chrono::milliseconds timeout,
|
|
std::chrono::milliseconds sampleInterval = 500ms,
|
|
uint64_t minimumDocuments = 0) {
|
|
DrainObservation observation;
|
|
observation.minimumDocuments = minimumDocuments;
|
|
const auto start = std::chrono::steady_clock::now();
|
|
const auto deadline = start + timeout;
|
|
constexpr int stableRequired = 5;
|
|
int stableCount = 0;
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
auto snap = DaemonSnapshot::capture(client);
|
|
observation.polls++;
|
|
observation.peakPostIngestQueued =
|
|
std::max(observation.peakPostIngestQueued, snap.postIngestQueued);
|
|
observation.peakPostIngestInflight =
|
|
std::max(observation.peakPostIngestInflight, snap.postIngestInflight);
|
|
observation.peakWorkerActive = std::max(observation.peakWorkerActive, snap.workerActive);
|
|
observation.peakWorkerQueued = std::max(observation.peakWorkerQueued, snap.workerQueued);
|
|
observation.peakDeferredQueueDepth =
|
|
std::max(observation.peakDeferredQueueDepth, snap.deferredQueueDepth);
|
|
observation.peakCpuPercent = std::max(observation.peakCpuPercent, snap.cpuUsagePercent);
|
|
observation.peakMemoryMb = std::max(observation.peakMemoryMb, snap.memoryUsageMb);
|
|
observation.maxPressureLevel = std::max(
|
|
observation.maxPressureLevel, static_cast<uint32_t>(std::max(0, snap.pressureLevel)));
|
|
observation.documentsBelowMinimumPolls += snap.documentsTotal < minimumDocuments ? 1u : 0u;
|
|
observation.postIngestQueuedPolls += snap.postIngestQueued > 0 ? 1u : 0u;
|
|
observation.postIngestInflightPolls += snap.postIngestInflight > 0 ? 1u : 0u;
|
|
observation.workerActivePolls += snap.workerActive > 0 ? 1u : 0u;
|
|
observation.workerQueuedPolls += snap.workerQueued > 0 ? 1u : 0u;
|
|
observation.deferredQueuePolls += snap.deferredQueueDepth > 0 ? 1u : 0u;
|
|
observation.coordinatedWritePolls +=
|
|
(snap.writeQueueDepth > 0 || snap.writeInFlight > 0) ? 1u : 0u;
|
|
|
|
const bool drained = writePipelineDrained(snap, minimumDocuments);
|
|
if (drained) {
|
|
++stableCount;
|
|
if (stableCount >= stableRequired) {
|
|
observation.drained = true;
|
|
observation.stablePolls = stableCount;
|
|
observation.finalSnapshot = snap;
|
|
observation.elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
return observation;
|
|
}
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
|
|
std::this_thread::sleep_for(sampleInterval);
|
|
}
|
|
|
|
observation.stablePolls = stableCount;
|
|
observation.elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
observation.finalSnapshot = DaemonSnapshot::capture(client);
|
|
return observation;
|
|
}
|
|
|
|
IdleBaselineObservation sampleIdleBaseline(DaemonClient& client, std::chrono::milliseconds duration,
|
|
std::chrono::milliseconds sampleInterval) {
|
|
IdleBaselineObservation observation;
|
|
observation.durationMs = static_cast<uint64_t>(std::max<int64_t>(0, duration.count()));
|
|
if (duration <= 0ms) {
|
|
return observation;
|
|
}
|
|
|
|
const auto start = std::chrono::steady_clock::now();
|
|
const auto deadline = start + duration;
|
|
double cpuSum = 0.0;
|
|
double memorySum = 0.0;
|
|
|
|
while (true) {
|
|
auto snap = DaemonSnapshot::capture(client);
|
|
observation.samples++;
|
|
cpuSum += snap.cpuUsagePercent;
|
|
memorySum += snap.memoryUsageMb;
|
|
observation.peakCpuPercent = std::max(observation.peakCpuPercent, snap.cpuUsagePercent);
|
|
observation.peakMemoryMb = std::max(observation.peakMemoryMb, snap.memoryUsageMb);
|
|
observation.maxPostIngestQueued =
|
|
std::max(observation.maxPostIngestQueued, snap.postIngestQueued);
|
|
observation.maxPostIngestInflight =
|
|
std::max(observation.maxPostIngestInflight, snap.postIngestInflight);
|
|
observation.maxActiveConnections =
|
|
std::max(observation.maxActiveConnections, snap.activeConnections);
|
|
observation.maxPressureLevel = std::max(
|
|
observation.maxPressureLevel, static_cast<uint32_t>(std::max(0, snap.pressureLevel)));
|
|
|
|
if (std::chrono::steady_clock::now() >= deadline) {
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(sampleInterval);
|
|
}
|
|
|
|
if (observation.samples > 0) {
|
|
observation.meanCpuPercent = cpuSum / static_cast<double>(observation.samples);
|
|
observation.meanMemoryMb = memorySum / static_cast<double>(observation.samples);
|
|
}
|
|
observation.durationMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
return observation;
|
|
}
|
|
|
|
bool waitForDrain(DaemonClient& client, std::chrono::seconds timeout) {
|
|
return waitForDrainObservation(client, timeout).drained;
|
|
}
|
|
|
|
IdleWakeProbeObservation runIdleWakeProbe(const BenchConfig& cfg, const fs::path& socketPath,
|
|
DaemonClient& monitorClient) {
|
|
IdleWakeProbeObservation observation;
|
|
observation.enabled = cfg.idleProbeEnabled;
|
|
if (!cfg.idleProbeEnabled) {
|
|
return observation;
|
|
}
|
|
|
|
observation.attempted = true;
|
|
const auto sampleInterval = std::chrono::milliseconds(cfg.idleProbeSampleMs);
|
|
observation.preProbeDrain = waitForDrainObservation(
|
|
monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs), sampleInterval);
|
|
if (!observation.preProbeDrain.drained) {
|
|
return observation;
|
|
}
|
|
|
|
observation.idleBaseline = sampleIdleBaseline(
|
|
monitorClient, std::chrono::milliseconds(cfg.idleProbeBaselineMs), sampleInterval);
|
|
observation.beforeSnapshot = DaemonSnapshot::capture(monitorClient);
|
|
|
|
struct ProbeAddResult {
|
|
bool success{false};
|
|
std::string error;
|
|
int64_t latencyUs{0};
|
|
};
|
|
|
|
auto addFuture = std::async(std::launch::async, [socketPath, &cfg]() {
|
|
ProbeAddResult result;
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = socketPath;
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = std::chrono::seconds(15);
|
|
ccfg.singleUseConnections = true;
|
|
DaemonClient client(ccfg);
|
|
|
|
AddDocumentRequest req;
|
|
req.name = "idle_probe_" + std::to_string(::getpid()) + ".md";
|
|
req.content = generateDocument(
|
|
777, 0, std::max<size_t>(256, std::min<size_t>(1024, cfg.docSizeBytes)));
|
|
req.tags = {"bench", "idle-probe"};
|
|
req.collection = "bench_idle_probe";
|
|
req.noEmbeddings = true;
|
|
|
|
const auto start = std::chrono::steady_clock::now();
|
|
auto addResult = yams::cli::run_sync(client.streamingAddDocument(req), 15s);
|
|
result.latencyUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - start)
|
|
.count();
|
|
result.success = static_cast<bool>(addResult);
|
|
if (!addResult) {
|
|
result.error = addResult.error().message;
|
|
}
|
|
return result;
|
|
});
|
|
|
|
const auto probeStart = std::chrono::steady_clock::now();
|
|
const auto detectDeadline =
|
|
probeStart + std::chrono::milliseconds(cfg.idleProbeDetectTimeoutMs);
|
|
while (std::chrono::steady_clock::now() < detectDeadline) {
|
|
if (!observation.addCompleted && addFuture.wait_for(0ms) == std::future_status::ready) {
|
|
const auto addResult = addFuture.get();
|
|
observation.addCompleted = true;
|
|
observation.addSucceeded = addResult.success;
|
|
observation.addLatencyUs =
|
|
static_cast<uint64_t>(std::max<int64_t>(0, addResult.latencyUs));
|
|
observation.addError = addResult.error;
|
|
}
|
|
|
|
auto snap = DaemonSnapshot::capture(monitorClient);
|
|
if (!observation.workVisible &&
|
|
(snap.documentsTotal > observation.beforeSnapshot.documentsTotal ||
|
|
snap.postIngestQueued > 0 || snap.postIngestInflight > 0 || snap.searchQueued > 0 ||
|
|
snap.searchActive > 0)) {
|
|
observation.workVisible = true;
|
|
observation.workVisibleUs = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - probeStart)
|
|
.count();
|
|
observation.visibleSnapshot = snap;
|
|
}
|
|
|
|
if (observation.workVisible && observation.addCompleted) {
|
|
break;
|
|
}
|
|
std::this_thread::sleep_for(sampleInterval);
|
|
}
|
|
|
|
if (!observation.addCompleted &&
|
|
addFuture.wait_for(std::chrono::seconds(20)) == std::future_status::ready) {
|
|
const auto addResult = addFuture.get();
|
|
observation.addCompleted = true;
|
|
observation.addSucceeded = addResult.success;
|
|
observation.addLatencyUs = static_cast<uint64_t>(std::max<int64_t>(0, addResult.latencyUs));
|
|
observation.addError = addResult.error;
|
|
}
|
|
|
|
if (observation.visibleSnapshot.maxConnections == 0) {
|
|
observation.visibleSnapshot = DaemonSnapshot::capture(monitorClient);
|
|
}
|
|
|
|
if (observation.addSucceeded) {
|
|
observation.postProbeDrain = waitForDrainObservation(
|
|
monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs), sampleInterval);
|
|
}
|
|
|
|
return observation;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Output helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
void emitJsonl(const fs::path& outputPath, const json& record) {
|
|
json enriched = record;
|
|
enriched["schema_version"] = "multi_client_record_v4";
|
|
if (const auto* v = std::getenv("YAMS_BENCH_RUN_ID"); v && *v) {
|
|
enriched["run_id"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_PHASE"); v && *v) {
|
|
enriched["run_phase"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_CASE"); v && *v) {
|
|
enriched["run_case"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_TRANSPORT"); v && *v) {
|
|
enriched["run_transport"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_GIT_SHA"); v && *v) {
|
|
enriched["git_sha"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_BACKEND"); v && *v) {
|
|
enriched["run_backend"] = v;
|
|
}
|
|
if (const auto* v = std::getenv("YAMS_BENCH_AUTH_MODE"); v && *v) {
|
|
enriched["run_auth_mode"] = v;
|
|
}
|
|
|
|
fs::create_directories(outputPath.parent_path());
|
|
std::ofstream out(outputPath, std::ios::app);
|
|
if (out) {
|
|
out << enriched.dump() << '\n';
|
|
}
|
|
}
|
|
|
|
std::optional<int64_t> jsonToInt64(const nlohmann::json& value) {
|
|
try {
|
|
if (value.is_number_integer()) {
|
|
return value.get<int64_t>();
|
|
}
|
|
if (value.is_number_unsigned()) {
|
|
return static_cast<int64_t>(value.get<uint64_t>());
|
|
}
|
|
if (value.is_number_float()) {
|
|
return static_cast<int64_t>(value.get<double>());
|
|
}
|
|
if (value.is_string()) {
|
|
const auto& s = value.get_ref<const std::string&>();
|
|
if (!s.empty()) {
|
|
return std::stoll(s);
|
|
}
|
|
}
|
|
} catch (const std::exception&) {
|
|
return std::nullopt;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<int64_t> jsonGetInt64AnyKey(const nlohmann::json& object,
|
|
std::initializer_list<const char*> keys) {
|
|
if (!object.is_object()) {
|
|
return std::nullopt;
|
|
}
|
|
for (const auto* key : keys) {
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
auto it = object.find(key);
|
|
if (it == object.end()) {
|
|
continue;
|
|
}
|
|
if (auto parsed = jsonToInt64(*it); parsed.has_value()) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
void printPercentiles(const std::string& label, const PercentileStats& stats) {
|
|
std::cout << " " << label << ": n=" << stats.count << " min=" << stats.min << "us"
|
|
<< " p50=" << stats.p50 << "us"
|
|
<< " p95=" << stats.p95 << "us"
|
|
<< " p99=" << stats.p99 << "us"
|
|
<< " max=" << stats.max << "us"
|
|
<< " mean=" << std::fixed << std::setprecision(0) << stats.mean << "us" << std::endl;
|
|
}
|
|
|
|
void printDrainAndIdleProbeSummary(const DrainObservation& drainObservation,
|
|
const IdleWakeProbeObservation& idleProbe) {
|
|
std::cout << " Drain time: " << drainObservation.elapsedMs << " ms\n";
|
|
if (idleProbe.attempted) {
|
|
std::cout << " Idle probe: baseline_cpu=" << std::fixed << std::setprecision(2)
|
|
<< idleProbe.idleBaseline.meanCpuPercent
|
|
<< "% peak=" << idleProbe.idleBaseline.peakCpuPercent
|
|
<< "% work_visible=" << idleProbe.workVisibleUs
|
|
<< "us add_latency=" << idleProbe.addLatencyUs
|
|
<< "us post_drain=" << idleProbe.postProbeDrain.elapsedMs << "ms\n";
|
|
}
|
|
}
|
|
|
|
// Create harness options tuned for benchmarking:
|
|
// - Disable model provider (we don't need embeddings for ingestion benchmarks)
|
|
// - Use mock model provider as fallback
|
|
// - No auto-loaded plugins (reduce startup variance)
|
|
// Overloads accept a BenchConfig to enable plugins, real models, and custom data dirs.
|
|
DaemonHarnessOptions benchHarnessOptions() {
|
|
return DaemonHarnessOptions{
|
|
.enableModelProvider = false,
|
|
.useMockModelProvider = true,
|
|
.autoLoadPlugins = false,
|
|
.enableAutoRepair = true,
|
|
.isolateState = true,
|
|
.isolateEnvironment = true,
|
|
};
|
|
}
|
|
|
|
DaemonHarnessOptions benchHarnessOptions(const BenchConfig& cfg) {
|
|
DaemonHarnessOptions opts{
|
|
.enableModelProvider = cfg.enablePlugins || cfg.useRealModelProvider,
|
|
.useMockModelProvider = !cfg.useRealModelProvider,
|
|
.autoLoadPlugins = cfg.enablePlugins,
|
|
.enableAutoRepair = true,
|
|
.isolateState = true,
|
|
.isolateEnvironment = true,
|
|
.pluginDir = cfg.pluginDir,
|
|
.dataDir = cfg.dataDir,
|
|
.configPath = cfg.configPath,
|
|
.trustedPluginPaths = cfg.trustedPluginPaths,
|
|
};
|
|
return opts;
|
|
}
|
|
|
|
// Startup timeout: 30s to handle TSan/debug builds where init is ~5-10x slower
|
|
constexpr auto kStartTimeout = std::chrono::seconds(30);
|
|
constexpr auto kCorpusStartTimeout = std::chrono::seconds(180);
|
|
|
|
std::chrono::seconds benchStartTimeout(const BenchConfig& cfg) {
|
|
return cfg.dataDir.has_value() ? kCorpusStartTimeout : kStartTimeout;
|
|
}
|
|
|
|
TEST_CASE("Multi-client benchmark allows explicit corpora a longer startup window",
|
|
"[multi-client][config]") {
|
|
BenchConfig synthetic;
|
|
CHECK(benchStartTimeout(synthetic) == kStartTimeout);
|
|
|
|
BenchConfig corpus;
|
|
corpus.dataDir = fs::path("symbol-rich-corpus");
|
|
CHECK(benchStartTimeout(corpus) == kCorpusStartTimeout);
|
|
}
|
|
|
|
bool startHarnessWithRetry(DaemonHarness& harness, std::chrono::milliseconds timeout,
|
|
int maxAttempts = 3) {
|
|
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
|
if (harness.start(timeout)) {
|
|
return true;
|
|
}
|
|
harness.stop();
|
|
std::this_thread::sleep_for(500ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// =============================================================================
|
|
// TEST CASES
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Multi-client ingestion: baseline single client",
|
|
"[!benchmark][multi-client][ingestion]") {
|
|
auto cfg = BenchConfig::fromEnv();
|
|
cfg.numClients = 1; // Force single client for baseline
|
|
|
|
DaemonHarness harness(benchHarnessOptions(cfg));
|
|
REQUIRE(harness.start(benchStartTimeout(cfg)));
|
|
|
|
// Create client
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
// Ingest documents
|
|
std::vector<int64_t> addLatencies;
|
|
auto startTime = std::chrono::steady_clock::now();
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
auto doc = generateDocument(0, i, cfg.docSizeBytes);
|
|
AddDocumentRequest req;
|
|
req.name = "baseline_doc_" + std::to_string(i) + ".md";
|
|
req.content = std::move(doc);
|
|
req.tags = {"bench", "baseline"};
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto result = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (result) {
|
|
addLatencies.push_back(latUs);
|
|
} else {
|
|
WARN("Add failed at doc " << i << ": " << result.error().message);
|
|
}
|
|
}
|
|
|
|
auto elapsed = std::chrono::steady_clock::now() - startTime;
|
|
double elapsedSec = std::chrono::duration<double>(elapsed).count();
|
|
double throughput = static_cast<double>(addLatencies.size()) / elapsedSec;
|
|
|
|
// Wait for pipeline drain and then probe idle->work transition once.
|
|
const auto drainObservation =
|
|
waitForDrainObservation(client, std::chrono::seconds(cfg.drainTimeoutSecs));
|
|
REQUIRE(drainObservation.drained);
|
|
const auto idleProbe = runIdleWakeProbe(cfg, harness.socketPath(), client);
|
|
|
|
auto snap = DaemonSnapshot::capture(client);
|
|
const auto recovery = runRecoveryCheck(harness.socketPath());
|
|
auto stats = PercentileStats::compute(addLatencies);
|
|
|
|
std::cout << "\n=== Single Client Baseline ===\n";
|
|
std::cout << " Docs ingested: " << addLatencies.size() << "/" << cfg.docsPerClient << "\n";
|
|
std::cout << " Elapsed: " << std::fixed << std::setprecision(2) << elapsedSec << "s\n";
|
|
std::cout << " Throughput: " << throughput << " docs/s\n";
|
|
printPercentiles("Add latency", stats);
|
|
printDrainAndIdleProbeSummary(drainObservation, idleProbe);
|
|
printRecoverySummary(recovery);
|
|
std::cout << " Final docs_total: " << snap.documentsTotal << "\n";
|
|
std::cout << " Memory: " << snap.memoryUsageMb << " MB\n\n";
|
|
|
|
CHECK(static_cast<int>(addLatencies.size()) == cfg.docsPerClient);
|
|
CHECK(throughput > 0.0);
|
|
CHECK(recovery.ok());
|
|
|
|
// Emit JSONL record for regression tracking
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "baseline_single_client"},
|
|
{"num_clients", 1},
|
|
{"docs_per_client", cfg.docsPerClient},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"elapsed_seconds", elapsedSec},
|
|
{"throughput_docs_per_sec", throughput},
|
|
{"add_latency", stats.toJson()},
|
|
{"drain_metrics", drainObservation.toJson()},
|
|
{"idle_probe", idleProbe.toJson()},
|
|
{"recovery", recovery.toJson()},
|
|
{"daemon_snapshot", snap.toJson()},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: concurrent pure ingest",
|
|
"[!benchmark][multi-client][ingestion]") {
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
DaemonHarness harness(benchHarnessOptions(cfg));
|
|
REQUIRE(harness.start(kStartTimeout));
|
|
|
|
// Status-monitoring client (separate from worker clients)
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
monCfg.singleUseConnections = true;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
// Start time-series sampler
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, std::chrono::milliseconds(cfg.resourceSampleMs),
|
|
harness.dataDir());
|
|
|
|
// Shared counters
|
|
std::atomic<int> totalDocsIngested{0};
|
|
std::atomic<int> totalFailures{0};
|
|
std::vector<ClientResult> clientResults(cfg.numClients);
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
// Launch concurrent client threads
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(cfg.numClients);
|
|
|
|
for (int c = 0; c < cfg.numClients; ++c) {
|
|
threads.emplace_back([&, c]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
ClientResult& result = clientResults[c];
|
|
result.clientId = c;
|
|
|
|
auto clientStart = std::chrono::steady_clock::now();
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
auto doc = generateDocument(c, i, cfg.docSizeBytes);
|
|
AddDocumentRequest req;
|
|
req.name = "client" + std::to_string(c) + "_doc_" + std::to_string(i) + ".md";
|
|
req.content = std::move(doc);
|
|
req.tags = {"bench", "multi-client", "client-" + std::to_string(c)};
|
|
req.collection = "bench_client_" + std::to_string(c);
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
OpLatency op;
|
|
op.opType = "add";
|
|
op.clientId = c;
|
|
op.latencyUs = latUs;
|
|
op.success = static_cast<bool>(res);
|
|
if (!res)
|
|
op.errorMsg = res.error().message;
|
|
|
|
result.latencies.push_back(op);
|
|
|
|
if (res) {
|
|
result.docsIngested++;
|
|
totalDocsIngested.fetch_add(1);
|
|
} else {
|
|
result.failures++;
|
|
totalFailures.fetch_add(1);
|
|
}
|
|
}
|
|
|
|
auto clientEnd = std::chrono::steady_clock::now();
|
|
result.elapsedSeconds = std::chrono::duration<double>(clientEnd - clientStart).count();
|
|
result.addThroughputDocsPerSec =
|
|
(result.docsIngested > 0)
|
|
? static_cast<double>(result.docsIngested) / result.elapsedSeconds
|
|
: 0.0;
|
|
});
|
|
}
|
|
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
sampler.stop();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
double aggregateThroughput = static_cast<double>(totalDocsIngested.load()) / globalElapsed;
|
|
|
|
// Wait for pipeline drain and then probe idle->work transition once.
|
|
const auto drainObservation =
|
|
waitForDrainObservation(monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs));
|
|
const bool drained = drainObservation.drained;
|
|
const auto idleProbe = runIdleWakeProbe(cfg, harness.socketPath(), monitorClient);
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
const auto recovery = runRecoveryCheck(harness.socketPath());
|
|
|
|
// Compute aggregate latency stats
|
|
std::vector<int64_t> allAddLatencies;
|
|
for (auto& cr : clientResults) {
|
|
for (auto& op : cr.latencies) {
|
|
if (op.opType == "add" && op.success)
|
|
allAddLatencies.push_back(op.latencyUs);
|
|
}
|
|
}
|
|
auto aggStats = PercentileStats::compute(allAddLatencies);
|
|
|
|
std::map<std::string, int> failureBreakdown;
|
|
std::map<std::string, std::string> failureSamples;
|
|
for (const auto& cr : clientResults) {
|
|
for (const auto& op : cr.latencies) {
|
|
if (op.opType != "add" || op.success) {
|
|
continue;
|
|
}
|
|
const auto bucket = classifyFailureMessage(op.errorMsg);
|
|
failureBreakdown[bucket] += 1;
|
|
if (!op.errorMsg.empty() && !failureSamples.contains(bucket)) {
|
|
failureSamples[bucket] = op.errorMsg;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print summary
|
|
std::cout << "\n=== Concurrent Pure Ingest (N=" << cfg.numClients << ") ===\n";
|
|
const int expectedDocs = checkedMulToInt(cfg.numClients, cfg.docsPerClient);
|
|
std::cout << " Total docs: " << totalDocsIngested.load() << "/" << expectedDocs << "\n";
|
|
std::cout << " Total failures: " << totalFailures.load() << "\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
std::cout << " Aggregate throughput: " << aggregateThroughput << " docs/s\n";
|
|
printPercentiles("Add latency (all)", aggStats);
|
|
|
|
if (!failureBreakdown.empty()) {
|
|
std::cout << "\n Failure breakdown (add requests):\n";
|
|
for (const auto& [bucket, count] : failureBreakdown) {
|
|
std::cout << " " << bucket << ": " << count;
|
|
auto sampleIt = failureSamples.find(bucket);
|
|
if (sampleIt != failureSamples.end()) {
|
|
std::cout << " (sample: " << sampleIt->second << ")";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
}
|
|
|
|
std::cout << "\n Per-client breakdown:\n";
|
|
for (auto& cr : clientResults) {
|
|
std::cout << " Client " << cr.clientId << ": " << cr.docsIngested << " docs, "
|
|
<< std::fixed << std::setprecision(1) << cr.addThroughputDocsPerSec
|
|
<< " docs/s, failures=" << cr.failures << "\n";
|
|
}
|
|
|
|
// Fairness metric: coefficient of variation of per-client throughput
|
|
{
|
|
std::vector<double> perClientThroughput;
|
|
for (auto& cr : clientResults) {
|
|
if (cr.addThroughputDocsPerSec > 0)
|
|
perClientThroughput.push_back(cr.addThroughputDocsPerSec);
|
|
}
|
|
if (perClientThroughput.size() > 1) {
|
|
double mean =
|
|
std::accumulate(perClientThroughput.begin(), perClientThroughput.end(), 0.0) /
|
|
static_cast<double>(perClientThroughput.size());
|
|
double variance = 0.0;
|
|
for (auto v : perClientThroughput)
|
|
variance += (v - mean) * (v - mean);
|
|
variance /= static_cast<double>(perClientThroughput.size());
|
|
double cv = (mean > 0) ? std::sqrt(variance) / mean : 0.0;
|
|
std::cout << " Fairness (CV of throughput): " << std::fixed << std::setprecision(3)
|
|
<< cv << " (lower = more fair)\n";
|
|
}
|
|
}
|
|
|
|
std::cout << " Queue drained: " << (drained ? "yes" : "NO") << "\n";
|
|
printDrainAndIdleProbeSummary(drainObservation, idleProbe);
|
|
printRecoverySummary(recovery);
|
|
std::cout << " Final docs_total: " << finalSnap.documentsTotal << "\n";
|
|
std::cout << " Final memory: " << finalSnap.memoryUsageMb << " MB\n\n";
|
|
|
|
CHECK(totalDocsIngested.load() == expectedDocs);
|
|
CHECK(drained);
|
|
CHECK(recovery.ok());
|
|
|
|
// Emit JSONL
|
|
json perClient = json::array();
|
|
for (auto& cr : clientResults) {
|
|
perClient.push_back(json{
|
|
{"client_id", cr.clientId},
|
|
{"docs_ingested", cr.docsIngested},
|
|
{"throughput_docs_per_sec", cr.addThroughputDocsPerSec},
|
|
{"elapsed_seconds", cr.elapsedSeconds},
|
|
{"failures", cr.failures},
|
|
});
|
|
}
|
|
|
|
// Time-series data
|
|
json timeSeries = json::array();
|
|
for (auto& s : sampler.getSamples()) {
|
|
timeSeries.push_back(json{{"elapsed_ms", s.elapsedMs}, {"snapshot", s.snap.toJson()}});
|
|
}
|
|
|
|
json failureBreakdownJson = json::object();
|
|
for (const auto& [bucket, count] : failureBreakdown) {
|
|
failureBreakdownJson[bucket] = count;
|
|
}
|
|
|
|
json failureSamplesJson = json::object();
|
|
for (const auto& [bucket, sample] : failureSamples) {
|
|
failureSamplesJson[bucket] = sample;
|
|
}
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "concurrent_pure_ingest"},
|
|
{"num_clients", cfg.numClients},
|
|
{"docs_per_client", cfg.docsPerClient},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"total_docs_ingested", totalDocsIngested.load()},
|
|
{"total_failures", totalFailures.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"aggregate_throughput_docs_per_sec", aggregateThroughput},
|
|
{"add_latency", aggStats.toJson()},
|
|
{"failure_breakdown", failureBreakdownJson},
|
|
{"failure_samples", failureSamplesJson},
|
|
{"per_client", perClient},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"drain_metrics", drainObservation.toJson()},
|
|
{"idle_probe", idleProbe.toJson()},
|
|
{"recovery", recovery.toJson()},
|
|
{"time_series", timeSeries},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
{"drained", drained},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: mixed read/write workload",
|
|
"[!benchmark][multi-client][mixed]") {
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
DaemonHarness harness(benchHarnessOptions(cfg));
|
|
REQUIRE(harness.start(benchStartTimeout(cfg)));
|
|
|
|
// Monitor client
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
// Pre-seed corpus so search queries return results
|
|
if (cfg.warmupDocs > 0) {
|
|
ClientConfig warmCfg;
|
|
warmCfg.socketPath = harness.socketPath();
|
|
warmCfg.autoStart = false;
|
|
warmCfg.requestTimeout = 30s;
|
|
warmCfg.singleUseConnections = true;
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
std::cout << " Pre-seeding " << cfg.warmupDocs << " warmup docs...\n";
|
|
for (int i = 0; i < cfg.warmupDocs; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "warmup_doc_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(99, i, cfg.docSizeBytes);
|
|
req.tags = {"warmup"};
|
|
auto res = yams::cli::run_sync(warmClient.streamingAddDocument(req), 30s);
|
|
if (!res) {
|
|
WARN("Warmup doc " << i << " failed: " << res.error().message);
|
|
}
|
|
}
|
|
// Wait for warmup to settle
|
|
(void)waitForDrain(warmClient, 30s);
|
|
std::this_thread::sleep_for(1s);
|
|
}
|
|
|
|
constexpr std::string_view kHydrationGraphSymbol = "benchHydratedSymbol";
|
|
if (cfg.profileHydrationSurfaces) {
|
|
ClientConfig setupCfg;
|
|
setupCfg.socketPath = harness.socketPath();
|
|
setupCfg.autoStart = false;
|
|
setupCfg.requestTimeout = 30s;
|
|
setupCfg.singleUseConnections = true;
|
|
DaemonClient setupClient(setupCfg);
|
|
|
|
const auto sourcePath = harness.dataDir() / "bench_hydration_source.cpp";
|
|
const std::string source = "int benchHydratedSymbol() {\n return 42;\n}\n";
|
|
{
|
|
std::ofstream out(sourcePath);
|
|
REQUIRE(out.good());
|
|
out << source;
|
|
}
|
|
|
|
AddDocumentRequest addReq;
|
|
addReq.name = sourcePath.string();
|
|
addReq.content = source;
|
|
addReq.tags = {"bench", "hydration"};
|
|
auto addResult = yams::cli::run_sync(setupClient.streamingAddDocument(addReq), 30s);
|
|
REQUIRE(addResult);
|
|
|
|
(void)waitForDrain(setupClient, 30s);
|
|
|
|
auto* serviceManager = harness.daemon()->getServiceManager();
|
|
REQUIRE(serviceManager != nullptr);
|
|
auto metadataRepo = serviceManager->getMetadataRepo();
|
|
REQUIRE(metadataRepo != nullptr);
|
|
auto kgStore = metadataRepo->getKnowledgeGraphStore();
|
|
REQUIRE(kgStore != nullptr);
|
|
yams::metadata::KGNode node;
|
|
node.nodeKey = "function:bench::benchHydratedSymbol@" + sourcePath.string();
|
|
node.label = std::string(kHydrationGraphSymbol);
|
|
node.type = "function";
|
|
node.properties = nlohmann::json{{"file_path", sourcePath.string()},
|
|
{"qualified_name", "bench::benchHydratedSymbol"},
|
|
{"start_line", 1},
|
|
{"end_line", 3}}
|
|
.dump();
|
|
REQUIRE(kgStore->upsertNode(node));
|
|
}
|
|
|
|
const auto resourceBaseline = DaemonSnapshot::capture(monitorClient, harness.dataDir());
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, std::chrono::milliseconds(cfg.resourceSampleMs),
|
|
harness.dataDir());
|
|
|
|
std::atomic<int> totalAdds{0};
|
|
std::atomic<int> totalSearches{0};
|
|
std::atomic<int> totalLists{0};
|
|
std::atomic<int> totalFailures{0};
|
|
OutputLivenessCounters searchOutputLiveness;
|
|
OutputLivenessCounters grepOutputLiveness;
|
|
OutputLivenessCounters graphOutputLiveness;
|
|
std::vector<ClientResult> clientResults(cfg.numClients);
|
|
std::mutex hydrationSurfaceMutex;
|
|
std::vector<int64_t> grepContentRetrievalMs;
|
|
std::vector<int64_t> grepMatchRenderUs;
|
|
std::vector<int64_t> grepWorkerCriticalTotalMs;
|
|
std::vector<int64_t> graphSnippetRenderUs;
|
|
std::vector<int64_t> graphSnippetsRendered;
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(cfg.numClients + (cfg.profileHydrationSurfaces ? 1 : 0));
|
|
|
|
for (int c = 0; c < cfg.numClients; ++c) {
|
|
threads.emplace_back([&, c]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
ccfg.singleUseConnections = true;
|
|
DaemonClient client(ccfg);
|
|
|
|
ClientResult& result = clientResults[c];
|
|
result.clientId = c;
|
|
|
|
std::mt19937 rng(static_cast<unsigned>(c * 1000 + 42));
|
|
std::uniform_real_distribution<double> opDist(0.0, 1.0);
|
|
|
|
auto clientStart = std::chrono::steady_clock::now();
|
|
int docIdx = 0;
|
|
|
|
// Total operations = docsPerClient ingestions + proportional reads
|
|
int totalOps = cfg.mixedOpsPerClient;
|
|
if (totalOps <= 0 && cfg.searchRatio >= 1.0) {
|
|
totalOps = cfg.docsPerClient;
|
|
} else if (totalOps <= 0 && cfg.searchRatio > 0.0) {
|
|
totalOps = static_cast<int>(static_cast<double>(cfg.docsPerClient) /
|
|
(1.0 - cfg.searchRatio));
|
|
} else if (totalOps <= 0) {
|
|
totalOps = cfg.docsPerClient;
|
|
}
|
|
|
|
for (int op = 0; op < totalOps; ++op) {
|
|
double roll = opDist(rng);
|
|
bool doSearch = (roll < cfg.searchRatio) && (docIdx > 0 || cfg.warmupDocs > 0);
|
|
bool doList = (roll >= cfg.searchRatio && roll < cfg.searchRatio + 0.05);
|
|
|
|
if (doSearch) {
|
|
// Search operation
|
|
SearchRequest req;
|
|
req.query = cfg.searchQuery;
|
|
req.searchType = cfg.searchType;
|
|
req.limit = cfg.searchLimit;
|
|
req.pathsOnly = cfg.searchPathsOnly;
|
|
req.snippetHydrationTimeoutMs = cfg.snippetHydrationTimeoutMs;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.search(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
OpLatency lat;
|
|
lat.opType = "search";
|
|
lat.clientId = c;
|
|
lat.latencyUs = latUs;
|
|
lat.success = static_cast<bool>(res);
|
|
if (!res)
|
|
lat.errorMsg = res.error().message;
|
|
result.latencies.push_back(lat);
|
|
|
|
if (res) {
|
|
result.searchesExecuted++;
|
|
totalSearches.fetch_add(1);
|
|
searchOutputLiveness.recordSuccess(!res.value().results.empty());
|
|
collectSearchHydrationTelemetry(res.value(), result);
|
|
} else {
|
|
searchOutputLiveness.recordError();
|
|
result.failures++;
|
|
totalFailures.fetch_add(1);
|
|
}
|
|
} else if (doList) {
|
|
// List operation
|
|
ListRequest req;
|
|
req.limit = 20;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.list(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
OpLatency lat;
|
|
lat.opType = "list";
|
|
lat.clientId = c;
|
|
lat.latencyUs = latUs;
|
|
lat.success = static_cast<bool>(res);
|
|
if (!res)
|
|
lat.errorMsg = res.error().message;
|
|
result.latencies.push_back(lat);
|
|
|
|
if (res) {
|
|
result.listsExecuted++;
|
|
totalLists.fetch_add(1);
|
|
} else {
|
|
result.failures++;
|
|
totalFailures.fetch_add(1);
|
|
}
|
|
} else {
|
|
// Add operation
|
|
if (docIdx >= cfg.docsPerClient)
|
|
continue; // Already ingested all docs for this client
|
|
|
|
auto doc = generateDocument(c, docIdx, cfg.docSizeBytes);
|
|
AddDocumentRequest req;
|
|
req.name =
|
|
"mixed_c" + std::to_string(c) + "_d" + std::to_string(docIdx) + ".md";
|
|
req.content = std::move(doc);
|
|
req.tags = {"bench", "mixed", "client-" + std::to_string(c)};
|
|
req.collection = "bench_mixed_" + std::to_string(c);
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
OpLatency lat;
|
|
lat.opType = "add";
|
|
lat.clientId = c;
|
|
lat.latencyUs = latUs;
|
|
lat.success = static_cast<bool>(res);
|
|
if (!res)
|
|
lat.errorMsg = res.error().message;
|
|
result.latencies.push_back(lat);
|
|
|
|
if (res) {
|
|
result.docsIngested++;
|
|
totalAdds.fetch_add(1);
|
|
} else {
|
|
result.failures++;
|
|
totalFailures.fetch_add(1);
|
|
}
|
|
docIdx++;
|
|
}
|
|
}
|
|
|
|
auto clientEnd = std::chrono::steady_clock::now();
|
|
result.elapsedSeconds = std::chrono::duration<double>(clientEnd - clientStart).count();
|
|
result.addThroughputDocsPerSec =
|
|
(result.docsIngested > 0)
|
|
? static_cast<double>(result.docsIngested) / result.elapsedSeconds
|
|
: 0.0;
|
|
});
|
|
}
|
|
|
|
if (cfg.profileHydrationSurfaces) {
|
|
threads.emplace_back([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
ccfg.singleUseConnections = true;
|
|
DaemonClient client(ccfg);
|
|
const int probeOps = std::clamp(cfg.mixedOpsPerClient / 4, 4, 25);
|
|
const auto recordGrepRender = [&](const yams::Result<GrepResponse>& result) {
|
|
if (!result) {
|
|
grepOutputLiveness.recordError();
|
|
return;
|
|
}
|
|
grepOutputLiveness.recordSuccess(!result.value().matches.empty());
|
|
std::lock_guard<std::mutex> lock(hydrationSurfaceMutex);
|
|
if (const auto value =
|
|
metricInt(result.value().searchStats, "content_retrieval_ms")) {
|
|
grepContentRetrievalMs.push_back(*value);
|
|
}
|
|
if (const auto value = metricInt(result.value().searchStats, "match_render_us")) {
|
|
grepMatchRenderUs.push_back(*value);
|
|
}
|
|
if (const auto value =
|
|
metricInt(result.value().searchStats, "worker_critical_total_ms")) {
|
|
grepWorkerCriticalTotalMs.push_back(*value);
|
|
}
|
|
};
|
|
const auto recordGraphRender = [&](const yams::Result<GraphExploreResponse>& result) {
|
|
if (!result) {
|
|
graphOutputLiveness.recordError();
|
|
return;
|
|
}
|
|
const auto& response = result.value();
|
|
graphOutputLiveness.recordSuccess(!response.entrySymbols.empty() ||
|
|
!response.files.empty() ||
|
|
!response.relationships.empty());
|
|
std::lock_guard<std::mutex> lock(hydrationSurfaceMutex);
|
|
graphSnippetRenderUs.push_back(static_cast<int64_t>(response.snippetRenderMicros));
|
|
graphSnippetsRendered.push_back(static_cast<int64_t>(response.snippetsRendered));
|
|
};
|
|
|
|
for (int i = 0; i < probeOps; ++i) {
|
|
GrepRequest grepReq;
|
|
grepReq.pattern = std::string(kHydrationGraphSymbol);
|
|
grepReq.literalText = true;
|
|
grepReq.maxMatches = 20;
|
|
auto grepResult = yams::cli::run_sync(client.grep(grepReq), 30s);
|
|
recordGrepRender(grepResult);
|
|
|
|
GraphExploreRequest graphReq;
|
|
graphReq.query = std::string(kHydrationGraphSymbol);
|
|
graphReq.maxFiles = 4;
|
|
auto graphResult = yams::cli::run_sync(client.call(graphReq), 30s);
|
|
recordGraphRender(graphResult);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
const uint64_t minimumDocuments =
|
|
resourceBaseline.documentsTotal + static_cast<uint64_t>(totalAdds.load());
|
|
const auto drainObservation = waitForDrainObservation(
|
|
monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs), 500ms, minimumDocuments);
|
|
sampler.stop();
|
|
const auto resourcePeaks = summarizeResourcePeaks(sampler.getSamples());
|
|
const bool drained = drainObservation.drained;
|
|
const auto idleProbe = runIdleWakeProbe(cfg, harness.socketPath(), monitorClient);
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient, harness.dataDir());
|
|
const auto recovery = runRecoveryCheck(harness.socketPath());
|
|
|
|
// Compute per-op-type latency stats
|
|
std::vector<int64_t> addLats, searchLats, listLats;
|
|
std::vector<int64_t> snippetHydrationUs, snippetLookupUs, snippetContentFetchUs,
|
|
snippetRenderUs;
|
|
int64_t snippetTimeoutHits = 0;
|
|
int64_t snippetHydrationCandidates = 0;
|
|
int64_t snippetHydrationMissing = 0;
|
|
int64_t snippetHydratedResults = 0;
|
|
for (auto& cr : clientResults) {
|
|
for (auto& op : cr.latencies) {
|
|
if (!op.success)
|
|
continue;
|
|
if (op.opType == "add")
|
|
addLats.push_back(op.latencyUs);
|
|
else if (op.opType == "search")
|
|
searchLats.push_back(op.latencyUs);
|
|
else if (op.opType == "list")
|
|
listLats.push_back(op.latencyUs);
|
|
}
|
|
snippetHydrationUs.insert(snippetHydrationUs.end(), cr.snippetHydrationUs.begin(),
|
|
cr.snippetHydrationUs.end());
|
|
snippetLookupUs.insert(snippetLookupUs.end(), cr.snippetLookupUs.begin(),
|
|
cr.snippetLookupUs.end());
|
|
snippetContentFetchUs.insert(snippetContentFetchUs.end(), cr.snippetContentFetchUs.begin(),
|
|
cr.snippetContentFetchUs.end());
|
|
snippetRenderUs.insert(snippetRenderUs.end(), cr.snippetRenderUs.begin(),
|
|
cr.snippetRenderUs.end());
|
|
snippetTimeoutHits = checkedMetricAdd(snippetTimeoutHits, cr.snippetTimeoutHits);
|
|
snippetHydrationCandidates =
|
|
checkedMetricAdd(snippetHydrationCandidates, cr.snippetHydrationCandidates);
|
|
snippetHydrationMissing =
|
|
checkedMetricAdd(snippetHydrationMissing, cr.snippetHydrationMissing);
|
|
snippetHydratedResults =
|
|
checkedMetricAdd(snippetHydratedResults, cr.snippetHydratedResults);
|
|
}
|
|
auto addStats = PercentileStats::compute(addLats);
|
|
auto searchStats = PercentileStats::compute(searchLats);
|
|
auto listStats = PercentileStats::compute(listLats);
|
|
auto snippetHydrationStats = PercentileStats::compute(snippetHydrationUs);
|
|
auto snippetLookupStats = PercentileStats::compute(snippetLookupUs);
|
|
auto snippetContentFetchStats = PercentileStats::compute(snippetContentFetchUs);
|
|
auto snippetRenderStats = PercentileStats::compute(snippetRenderUs);
|
|
auto grepContentRetrievalStats = PercentileStats::compute(grepContentRetrievalMs);
|
|
auto grepMatchRenderStats = PercentileStats::compute(grepMatchRenderUs);
|
|
auto grepWorkerCriticalTotalStats = PercentileStats::compute(grepWorkerCriticalTotalMs);
|
|
auto graphSnippetRenderStats = PercentileStats::compute(graphSnippetRenderUs);
|
|
const auto graphSnippetsRenderedTotal =
|
|
std::accumulate(graphSnippetsRendered.begin(), graphSnippetsRendered.end(), int64_t{0});
|
|
|
|
std::cout << "\n=== Mixed Read/Write Workload (N=" << cfg.numClients
|
|
<< ", search_ratio=" << cfg.searchRatio << ") ===\n";
|
|
std::cout << " Adds: " << totalAdds.load() << ", Searches: " << totalSearches.load()
|
|
<< ", Lists: " << totalLists.load() << ", Failures: " << totalFailures.load() << "\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
printPercentiles("Add latency", addStats);
|
|
printPercentiles("Search latency", searchStats);
|
|
printPercentiles("List latency", listStats);
|
|
std::cout << " Queue drained: " << (drained ? "yes" : "NO") << "\n";
|
|
printDrainAndIdleProbeSummary(drainObservation, idleProbe);
|
|
printRecoverySummary(recovery);
|
|
std::cout << " Final docs_total: " << finalSnap.documentsTotal << "\n\n";
|
|
|
|
if (cfg.searchRatio < 1.0) {
|
|
CHECK(totalAdds.load() > 0);
|
|
}
|
|
CHECK((totalAdds.load() + totalSearches.load() + totalLists.load()) > 0);
|
|
if (cfg.profileHydrationSurfaces) {
|
|
CHECK_FALSE(grepMatchRenderUs.empty());
|
|
CHECK_FALSE(graphSnippetRenderUs.empty());
|
|
CHECK(grepOutputLiveness.attempts.load(std::memory_order_relaxed) > 0);
|
|
CHECK(graphOutputLiveness.attempts.load(std::memory_order_relaxed) > 0);
|
|
}
|
|
CHECK(drained);
|
|
CHECK(recovery.ok());
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "mixed_read_write"},
|
|
{"num_clients", cfg.numClients},
|
|
{"docs_per_client", cfg.docsPerClient},
|
|
{"mixed_ops_per_client", cfg.mixedOpsPerClient},
|
|
{"search_ratio", cfg.searchRatio},
|
|
{"search_paths_only", cfg.searchPathsOnly},
|
|
{"snippet_hydration_timeout_ms", cfg.snippetHydrationTimeoutMs},
|
|
{"warmup_docs", cfg.warmupDocs},
|
|
{"total_adds", totalAdds.load()},
|
|
{"total_searches", totalSearches.load()},
|
|
{"total_lists", totalLists.load()},
|
|
{"total_failures", totalFailures.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"add_latency", addStats.toJson()},
|
|
{"search_latency", searchStats.toJson()},
|
|
{"list_latency", listStats.toJson()},
|
|
{"search_server",
|
|
{{"timing_snippet_hydration_us", snippetHydrationStats.toJson()},
|
|
{"timing_snippet_lookup_us", snippetLookupStats.toJson()},
|
|
{"timing_snippet_content_fetch_us", snippetContentFetchStats.toJson()},
|
|
{"timing_snippet_render_us", snippetRenderStats.toJson()},
|
|
{"snippet_timeout_hits", snippetTimeoutHits},
|
|
{"snippet_hydration_candidates", snippetHydrationCandidates},
|
|
{"snippet_hydration_missing", snippetHydrationMissing},
|
|
{"snippet_hydrated_results", snippetHydratedResults}}},
|
|
{"grep_server",
|
|
{{"content_retrieval_ms", grepContentRetrievalStats.toJson()},
|
|
{"match_render_us", grepMatchRenderStats.toJson()},
|
|
{"worker_critical_total_ms", grepWorkerCriticalTotalStats.toJson()}}},
|
|
{"graph_server",
|
|
{{"snippet_render_us", graphSnippetRenderStats.toJson()},
|
|
{"snippets_rendered", graphSnippetsRenderedTotal}}},
|
|
{"output_liveness",
|
|
{{"search", searchOutputLiveness.toJson()},
|
|
{"grep", grepOutputLiveness.toJson()},
|
|
{"graph", graphOutputLiveness.toJson()}}},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"drain_metrics", drainObservation.toJson()},
|
|
{"idle_probe", idleProbe.toJson()},
|
|
{"recovery", recovery.toJson()},
|
|
{"resource_baseline", resourceBaseline.toJson()},
|
|
{"resource_peaks", resourcePeaks.toJson()},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
{"drained", drained},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: socket scaling vs ux latency",
|
|
"[!benchmark][multi-client][ux-scaling]") {
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
// Three phases:
|
|
// 1. Warmup: seed a small searchable corpus.
|
|
// 2. Realistic mixed load: measure ingest + UX latency under pressure.
|
|
// 3. Deterministic control: force slot growth, then force shrink, and assert only on
|
|
// sampled slot metrics rather than transient active-connection counts.
|
|
constexpr size_t kInitialSlotLimit = 12;
|
|
constexpr size_t kSlotMax = 16;
|
|
constexpr size_t kSlotStep = 4;
|
|
ScopedConnectionSlotOverrides slotOverrides(static_cast<uint32_t>(kInitialSlotLimit),
|
|
static_cast<uint32_t>(kSlotMax),
|
|
static_cast<uint32_t>(kSlotStep));
|
|
yams::test::ScopedEnvVar maxActiveConn("YAMS_MAX_ACTIVE_CONN",
|
|
std::to_string(kInitialSlotLimit));
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(startHarnessWithRetry(harness, kStartTimeout));
|
|
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
// Pre-seed a small searchable corpus so UX requests hit real work.
|
|
{
|
|
ClientConfig warmCfg;
|
|
warmCfg.socketPath = harness.socketPath();
|
|
warmCfg.autoStart = false;
|
|
warmCfg.requestTimeout = 30s;
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
const int warmDocs = std::max(16, cfg.warmupDocs);
|
|
for (int i = 0; i < warmDocs; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "ux_scale_warmup_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(77, i, cfg.docSizeBytes);
|
|
req.tags = {"warmup", "ux-scaling"};
|
|
auto res = yams::cli::run_sync(warmClient.streamingAddDocument(req), 30s);
|
|
REQUIRE(res);
|
|
}
|
|
REQUIRE(waitForDrain(warmClient, 30s));
|
|
}
|
|
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, 100ms);
|
|
|
|
const int writerClients = std::max(2, cfg.numClients);
|
|
const int uxClients = std::max(4, checkedMulToInt(cfg.numClients, 3, kMaxBenchClients * 3));
|
|
const int writerOpsPerClient =
|
|
checkedMulToInt(cfg.docsPerClient, cfg.stressRounds, kMaxBenchOpsPerClient);
|
|
const int uxOpsPerClient =
|
|
checkedMulToInt(std::max(24, cfg.docsPerClient), cfg.stressRounds, kMaxBenchOpsPerClient);
|
|
const int cliProbeOpsPerCommand = cfg.cliProbeOpsPerCommand;
|
|
const size_t grownSlotTarget = std::min(kSlotMax, kInitialSlotLimit + kSlotStep);
|
|
// Allow one lingering request/observer connection beyond the steady monitor connection.
|
|
const size_t controlLightLoadTarget =
|
|
std::max<size_t>(3, std::min<size_t>(4, static_cast<size_t>(cfg.stressRounds + 2)));
|
|
const auto yamsBinary = findYamsBinary(cfg);
|
|
const bool cliStressProbesEnabled =
|
|
yamsBinary.path.has_value() && yamsBinary.usableForStress && cliProbeOpsPerCommand > 0;
|
|
|
|
std::atomic<int> addOps{0};
|
|
std::atomic<int> addFails{0};
|
|
std::atomic<int> uxOps{0};
|
|
std::atomic<int> uxFails{0};
|
|
std::atomic<int> retryAfterCount{0};
|
|
std::atomic<int> cliOps{0};
|
|
std::atomic<int> cliFails{0};
|
|
std::mutex latMutex;
|
|
std::vector<int64_t> addLatencies;
|
|
std::vector<int64_t> searchLatencies;
|
|
std::vector<int64_t> listLatencies;
|
|
std::vector<int64_t> statusLatencies;
|
|
std::vector<int64_t> cliListLatencies;
|
|
std::vector<int64_t> cliSearchLatencies;
|
|
std::vector<int64_t> cliStatusLatencies;
|
|
std::map<std::string, std::vector<int64_t>> cliStageLatencies;
|
|
std::vector<CliProbeResult> cliProbeResults;
|
|
std::vector<TimedLatencySample> hotspotSamples;
|
|
std::map<std::string, std::vector<int64_t>> opStageLatencies;
|
|
std::map<std::string, int> uxErrorCategories;
|
|
std::map<std::string, std::string> uxFailureSamples;
|
|
|
|
HeldSocketConnections heldConnections;
|
|
const bool heldConnectionsOpened =
|
|
heldConnections.open(harness.socketPath(), static_cast<int>(kInitialSlotLimit));
|
|
INFO("HeldSocketConnections.open initial ramp socket="
|
|
<< harness.socketPath().string() << " requested=" << kInitialSlotLimit << " opened="
|
|
<< heldConnections.openedCount() << " failed_index=" << heldConnections.lastFailedIndex()
|
|
<< " error='" << heldConnections.lastError() << "'");
|
|
REQUIRE(heldConnectionsOpened);
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(writerClients + uxClients);
|
|
|
|
for (int writer = 0; writer < writerClients; ++writer) {
|
|
threads.emplace_back([&, writer]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
std::vector<int64_t> localAdds;
|
|
for (int i = 0; i < writerOpsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name =
|
|
"ux_scale_writer_" + std::to_string(writer) + "_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(writer, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "ux-scaling", "writer-" + std::to_string(writer)};
|
|
req.collection = "ux_scaling_writer_" + std::to_string(writer);
|
|
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
const auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
addOps.fetch_add(1);
|
|
localAdds.push_back(latUs);
|
|
} else {
|
|
addFails.fetch_add(1);
|
|
}
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
hotspotSamples.push_back(TimedLatencySample{
|
|
.opType = "add",
|
|
.wallClockMs = static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count()),
|
|
.latencyUs = latUs,
|
|
.success = static_cast<bool>(res),
|
|
});
|
|
}
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
addLatencies.insert(addLatencies.end(), localAdds.begin(), localAdds.end());
|
|
});
|
|
}
|
|
|
|
for (int ux = 0; ux < uxClients; ++ux) {
|
|
threads.emplace_back([&]() {
|
|
std::vector<int64_t> localSearch;
|
|
std::vector<int64_t> localList;
|
|
std::vector<int64_t> localStatus;
|
|
std::map<std::string, int> localErrorCategories;
|
|
std::map<std::string, std::string> localFailureSamples;
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
|
|
for (int i = 0; i < uxOpsPerClient; ++i) {
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
bool success = false;
|
|
std::string errorMsg;
|
|
std::string opType;
|
|
|
|
switch (i % 3) {
|
|
case 0: {
|
|
opType = "status";
|
|
auto res = yams::cli::run_sync(client.status(), 10s);
|
|
success = static_cast<bool>(res);
|
|
if (!res) {
|
|
errorMsg = res.error().message;
|
|
}
|
|
if (res && res.value().retryAfterMs > 0) {
|
|
retryAfterCount.fetch_add(1);
|
|
}
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
const auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
if (success) {
|
|
localStatus.push_back(latUs);
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
if (res) {
|
|
appendStatusTimingStats(opStageLatencies, res.value().requestCounts,
|
|
latUs);
|
|
}
|
|
hotspotSamples.push_back(TimedLatencySample{
|
|
.opType = "status",
|
|
.wallClockMs = static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
t1 - globalStart)
|
|
.count()),
|
|
.latencyUs = latUs,
|
|
.success = success,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 1: {
|
|
opType = "list";
|
|
ListRequest req;
|
|
req.limit = 10;
|
|
auto res = yams::cli::run_sync(client.list(req), 10s);
|
|
success = static_cast<bool>(res);
|
|
if (!res) {
|
|
errorMsg = res.error().message;
|
|
}
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
const auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
if (success) {
|
|
localList.push_back(latUs);
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
if (res) {
|
|
appendStringTimingStats(opStageLatencies, "list",
|
|
res.value().listStats, latUs);
|
|
}
|
|
hotspotSamples.push_back(TimedLatencySample{
|
|
.opType = "list",
|
|
.wallClockMs = static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
t1 - globalStart)
|
|
.count()),
|
|
.latencyUs = latUs,
|
|
.success = success,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
opType = "search";
|
|
SearchRequest req;
|
|
req.query = cfg.searchQuery;
|
|
req.searchType = cfg.searchType;
|
|
req.limit = cfg.searchLimit;
|
|
auto res = yams::cli::run_sync(client.search(req), 10s);
|
|
success = static_cast<bool>(res);
|
|
if (!res) {
|
|
errorMsg = res.error().message;
|
|
}
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
if (success) {
|
|
localSearch.push_back(
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0)
|
|
.count());
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
hotspotSamples.push_back(TimedLatencySample{
|
|
.opType = "search",
|
|
.wallClockMs = static_cast<uint64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
t1 - globalStart)
|
|
.count()),
|
|
.latencyUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0)
|
|
.count(),
|
|
.success = success,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
uxOps.fetch_add(1);
|
|
if (!success) {
|
|
uxFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(errorMsg);
|
|
const auto key = opType + ":" + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!errorMsg.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = errorMsg;
|
|
}
|
|
}
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
searchLatencies.insert(searchLatencies.end(), localSearch.begin(), localSearch.end());
|
|
listLatencies.insert(listLatencies.end(), localList.begin(), localList.end());
|
|
statusLatencies.insert(statusLatencies.end(), localStatus.begin(), localStatus.end());
|
|
for (const auto& [key, count] : localErrorCategories) {
|
|
uxErrorCategories[key] += count;
|
|
}
|
|
for (const auto& [key, sample] : localFailureSamples) {
|
|
if (!uxFailureSamples.contains(key)) {
|
|
uxFailureSamples[key] = sample;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#ifndef _WIN32
|
|
if (cliStressProbesEnabled) {
|
|
threads.emplace_back([&, cliBin = yamsBinary.path.value()]() {
|
|
std::vector<int64_t> localCliList;
|
|
std::vector<int64_t> localCliSearch;
|
|
std::vector<int64_t> localCliStatus;
|
|
std::map<std::string, std::vector<int64_t>> localCliStageLatencies;
|
|
std::vector<CliProbeResult> localCliResults;
|
|
const auto cliProbeTimeout = std::chrono::seconds(cfg.cliProbeTimeoutSecs);
|
|
|
|
auto recordProbe = [&](const std::string& opType, const CliProbeResult& probe,
|
|
std::vector<int64_t>& storage) {
|
|
cliOps.fetch_add(1);
|
|
if (probe.success) {
|
|
storage.push_back(probe.latencyUs);
|
|
} else {
|
|
cliFails.fetch_add(1);
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
hotspotSamples.push_back(TimedLatencySample{
|
|
.opType = opType,
|
|
.wallClockMs =
|
|
static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - globalStart)
|
|
.count()),
|
|
.latencyUs = probe.latencyUs,
|
|
.success = probe.success,
|
|
});
|
|
for (const auto& [stage, elapsedUs] : probe.stageElapsedUs) {
|
|
localCliStageLatencies[opType + ":" + stage].push_back(elapsedUs);
|
|
}
|
|
localCliResults.push_back(probe);
|
|
};
|
|
|
|
for (int i = 0; i < cliProbeOpsPerCommand; ++i) {
|
|
recordProbe("cli_list",
|
|
runObservedCliProbe("cli_list", cliBin, harness.socketPath(),
|
|
{"list", "--limit", "10"}, cliProbeTimeout),
|
|
localCliList);
|
|
recordProbe("cli_status",
|
|
runObservedCliProbe("cli_status", cliBin, harness.socketPath(),
|
|
{"status"}, cliProbeTimeout),
|
|
localCliStatus);
|
|
recordProbe("cli_search",
|
|
runObservedCliProbe("cli_search", cliBin, harness.socketPath(),
|
|
buildCliSearchArgs(cfg), cliProbeTimeout),
|
|
localCliSearch);
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
cliListLatencies.insert(cliListLatencies.end(), localCliList.begin(),
|
|
localCliList.end());
|
|
cliSearchLatencies.insert(cliSearchLatencies.end(), localCliSearch.begin(),
|
|
localCliSearch.end());
|
|
cliStatusLatencies.insert(cliStatusLatencies.end(), localCliStatus.begin(),
|
|
localCliStatus.end());
|
|
for (auto& [stage, values] : localCliStageLatencies) {
|
|
auto& dest = cliStageLatencies[stage];
|
|
dest.insert(dest.end(), values.begin(), values.end());
|
|
}
|
|
cliProbeResults.insert(cliProbeResults.end(), localCliResults.begin(),
|
|
localCliResults.end());
|
|
});
|
|
}
|
|
#endif
|
|
|
|
for (auto& thread : threads) {
|
|
thread.join();
|
|
}
|
|
|
|
sampler.stop();
|
|
heldConnections.closeAll();
|
|
|
|
std::this_thread::sleep_for(500ms);
|
|
|
|
const auto globalEnd = std::chrono::steady_clock::now();
|
|
const double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
const auto drainObservation =
|
|
waitForDrainObservation(monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs));
|
|
const bool drained = drainObservation.drained;
|
|
const auto idleProbe = runIdleWakeProbe(cfg, harness.socketPath(), monitorClient);
|
|
const bool recoveryQuiesced =
|
|
waitForActiveConnectionsAtMost(monitorClient, controlLightLoadTarget, 20s);
|
|
const auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
const auto recovery = runRecoveryCheck(harness.socketPath());
|
|
const bool cliRecoveryAttempted = yamsBinary.path.has_value() && yamsBinary.usableForStress;
|
|
const auto cliRecoveryStatus =
|
|
cliRecoveryAttempted ? runObservedCliProbe("cli_status_recovery", yamsBinary.path.value(),
|
|
harness.socketPath(), {"status"}, 10s)
|
|
: CliProbeResult{};
|
|
const auto cliRecoveryList =
|
|
cliRecoveryAttempted
|
|
? runObservedCliProbe("cli_list_recovery", yamsBinary.path.value(),
|
|
harness.socketPath(), {"list", "--limit", "10"}, 10s)
|
|
: CliProbeResult{};
|
|
const bool cliRecoveryOk =
|
|
!cliRecoveryAttempted || (cliRecoveryStatus.success && cliRecoveryList.success);
|
|
const auto samples = sampler.getSamples();
|
|
const auto slotSummary = summarizeSlotScaling(samples);
|
|
const auto resourcePeaks = summarizeResourcePeaks(samples);
|
|
const auto hotspotSummary = summarizeLatencyHotspots(hotspotSamples);
|
|
const auto opStageSummary = summarizeTimingStats(opStageLatencies);
|
|
const auto addStats = PercentileStats::compute(addLatencies);
|
|
const auto searchStats = PercentileStats::compute(searchLatencies);
|
|
const auto listStats = PercentileStats::compute(listLatencies);
|
|
const auto statusStats = PercentileStats::compute(statusLatencies);
|
|
const auto cliListStats = PercentileStats::compute(cliListLatencies);
|
|
const auto cliSearchStats = PercentileStats::compute(cliSearchLatencies);
|
|
const auto cliStatusStats = PercentileStats::compute(cliStatusLatencies);
|
|
json cliProbeResultsJson = json::array();
|
|
for (const auto& probe : cliProbeResults) {
|
|
cliProbeResultsJson.push_back(probe.toJson());
|
|
}
|
|
std::map<std::string, int> cliFailureBuckets;
|
|
std::map<std::string, int> cliFailureModes;
|
|
std::map<std::string, std::string> cliFailureSamples;
|
|
for (const auto& probe : cliProbeResults) {
|
|
if (probe.success) {
|
|
continue;
|
|
}
|
|
cliFailureBuckets[probe.failureBucket] += 1;
|
|
cliFailureModes[probe.failureMode] += 1;
|
|
if (!probe.error.empty() && !cliFailureSamples.contains(probe.failureMode)) {
|
|
cliFailureSamples[probe.failureMode] = probe.error;
|
|
}
|
|
}
|
|
int unacceptableCliFailures = 0;
|
|
for (const auto& [mode, count] : cliFailureModes) {
|
|
if (mode != "timeout_under_load" && mode != "overload_under_load" &&
|
|
mode != "stress_expected") {
|
|
unacceptableCliFailures += count;
|
|
}
|
|
}
|
|
const bool observedGrowth =
|
|
slotSummary.peakLimit >= grownSlotTarget || slotSummary.growEvents > 0;
|
|
const bool observedShrink =
|
|
slotSummary.finalLimit <= kInitialSlotLimit || slotSummary.shrinkEvents > 0;
|
|
|
|
const double addThroughput =
|
|
(globalElapsed > 0.0) ? static_cast<double>(addOps.load()) / globalElapsed : 0.0;
|
|
const double uxThroughput =
|
|
(globalElapsed > 0.0) ? static_cast<double>(uxOps.load()) / globalElapsed : 0.0;
|
|
const double uxFailureRate =
|
|
(uxOps.load() > 0) ? static_cast<double>(uxFails.load()) / static_cast<double>(uxOps.load())
|
|
: 0.0;
|
|
|
|
std::cout << "\n=== Socket Scaling vs UX Latency ===\n";
|
|
std::cout << " Adds: " << addOps.load() << " (fails=" << addFails.load() << ")"
|
|
<< " UX ops: " << uxOps.load() << " (fails=" << uxFails.load() << ")\n";
|
|
std::cout << " Throughput: add=" << std::fixed << std::setprecision(1) << addThroughput
|
|
<< " docs/s, ux=" << uxThroughput << " ops/s\n";
|
|
std::cout << " Search config: daemon(type=" << cfg.searchType << ", limit=" << cfg.searchLimit
|
|
<< ", query=\"" << cfg.searchQuery << "\")"
|
|
<< " cli(type=" << cfg.cliSearchType << ", limit=" << cfg.cliSearchLimit
|
|
<< ", query=\"" << cfg.cliSearchQuery << "\")\n";
|
|
if (cliStressProbesEnabled) {
|
|
std::cout << " CLI probes: " << cliOps.load() << " (fails=" << cliFails.load()
|
|
<< ") binary=" << yamsBinary.path.value().string() << "\n";
|
|
} else {
|
|
std::cout << " CLI probes: skipped";
|
|
if (!yamsBinary.note.empty()) {
|
|
std::cout << " (" << yamsBinary.note << ")";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
if (!cliFailureModes.empty()) {
|
|
printCliProbeFailureSummary(cliFailureModes, cliFailureSamples);
|
|
}
|
|
if (!uxErrorCategories.empty()) {
|
|
std::cout << " UX failure categories:";
|
|
for (const auto& [key, count] : uxErrorCategories) {
|
|
std::cout << " " << key << "=" << count;
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
std::cout << " Slot scaling: initial=" << slotSummary.initialLimit
|
|
<< " peak=" << slotSummary.peakLimit << " final=" << slotSummary.finalLimit
|
|
<< " grow_events=" << slotSummary.growEvents
|
|
<< " shrink_events=" << slotSummary.shrinkEvents
|
|
<< " min_free=" << slotSummary.minFreeSlots
|
|
<< " peak_active=" << slotSummary.peakActiveConnections
|
|
<< " peak_util=" << std::setprecision(2) << (slotSummary.peakUtilization * 100.0)
|
|
<< "% retry_after_samples=" << slotSummary.retryAfterSamples
|
|
<< " observed_growth=" << (observedGrowth ? "yes" : "NO")
|
|
<< " observed_shrink=" << (observedShrink ? "yes" : "NO") << "\n";
|
|
printPercentiles("Add latency", addStats);
|
|
printPercentiles("Search latency", searchStats);
|
|
printPercentiles("List latency", listStats);
|
|
printPercentiles("Status latency", statusStats);
|
|
if (!opStageSummary.empty()) {
|
|
auto printStageP95 = [&](std::string_view key) {
|
|
auto it = opStageSummary.find(std::string(key));
|
|
if (it != opStageSummary.end() && it->is_object()) {
|
|
std::cout << " " << key << " p95=" << it->value("p95_us", 0)
|
|
<< "us mean=" << std::fixed << std::setprecision(1)
|
|
<< it->value("mean_us", 0.0) << "us\n";
|
|
}
|
|
};
|
|
std::cout << " Direct op overhead trace:\n";
|
|
printStageP95("list:phase_dispatch_total_us");
|
|
printStageP95("list:phase_dispatch_service_us");
|
|
printStageP95("list:phase_dispatch_map_us");
|
|
printStageP95("list:client_transport_overhead_us");
|
|
printStageP95("status:phase_dispatch_total_us");
|
|
printStageP95("status:client_transport_overhead_us");
|
|
if (finalSnap.addDispatchSamples > 0) {
|
|
const auto meanDispatch = static_cast<double>(finalSnap.addDispatchTotalUs) /
|
|
static_cast<double>(finalSnap.addDispatchSamples);
|
|
const auto meanFingerprint = static_cast<double>(finalSnap.addFingerprintTotalUs) /
|
|
static_cast<double>(finalSnap.addDispatchSamples);
|
|
const auto meanEnqueue = static_cast<double>(finalSnap.addEnqueueTotalUs) /
|
|
static_cast<double>(finalSnap.addDispatchSamples);
|
|
std::cout << " add:server_aggregate samples=" << finalSnap.addDispatchSamples
|
|
<< " mean_dispatch=" << std::fixed << std::setprecision(1) << meanDispatch
|
|
<< "us max_dispatch=" << finalSnap.addDispatchMaxUs
|
|
<< "us mean_fingerprint=" << meanFingerprint
|
|
<< "us max_fingerprint=" << finalSnap.addFingerprintMaxUs
|
|
<< "us mean_enqueue=" << meanEnqueue
|
|
<< "us max_enqueue=" << finalSnap.addEnqueueMaxUs << "us\n";
|
|
}
|
|
}
|
|
if (cliListStats.count + cliSearchStats.count + cliStatusStats.count > 0) {
|
|
printPercentiles("CLI list latency", cliListStats);
|
|
printPercentiles("CLI search latency", cliSearchStats);
|
|
printPercentiles("CLI status latency", cliStatusStats);
|
|
for (auto& [stage, values] : cliStageLatencies) {
|
|
auto stageStats = PercentileStats::compute(values);
|
|
printPercentiles("CLI stage " + stage, stageStats);
|
|
}
|
|
}
|
|
std::cout << " Resource peaks: memory=" << std::fixed << std::setprecision(1)
|
|
<< resourcePeaks.peakMemoryMb << " MB cpu=" << resourcePeaks.peakCpuPercent
|
|
<< "% rss=" << resourcePeaks.peakRssBytes
|
|
<< "B post_q=" << resourcePeaks.peakPostIngestQueued
|
|
<< " post_inflight=" << resourcePeaks.peakPostIngestInflight
|
|
<< " search_active=" << resourcePeaks.peakSearchActive
|
|
<< " search_q=" << resourcePeaks.peakSearchQueued
|
|
<< " db_write_wait=" << resourcePeaks.peakDbWritePoolWaiting
|
|
<< " db_read_wait=" << resourcePeaks.peakDbReadPoolWaiting
|
|
<< " write_apply_ms=" << resourcePeaks.peakWriteMaxBatchApplyMs
|
|
<< " write_wait_ms=" << resourcePeaks.peakWriteMaxBatchQueueWaitMs
|
|
<< " write_excess_wait_ms=" << resourcePeaks.peakWriteMaxBatchExcessQueueWaitMs
|
|
<< " fds=" << resourcePeaks.peakProcessFdCount
|
|
<< " (socket=" << resourcePeaks.peakProcessFdSockets
|
|
<< " pipe=" << resourcePeaks.peakProcessFdPipes
|
|
<< " file=" << resourcePeaks.peakProcessFdFiles
|
|
<< " dev=" << resourcePeaks.peakProcessFdDevices
|
|
<< " kqueue=" << resourcePeaks.peakProcessFdKqueues
|
|
<< " other=" << resourcePeaks.peakProcessFdOther << ")"
|
|
<< " post_content_index_calls=" << resourcePeaks.peakPostCommitContentIndexCalls
|
|
<< " post_content_index_total_ms=" << resourcePeaks.peakPostCommitContentIndexTotalMs
|
|
<< " post_content_index_max_ms=" << resourcePeaks.peakPostCommitContentIndexMaxMs
|
|
<< " client_pool_open=" << resourcePeaks.peakClientPoolOpenSockets
|
|
<< " client_pool_alive=" << resourcePeaks.peakClientPoolAliveConnections
|
|
<< " client_pool_in_use=" << resourcePeaks.peakClientPoolInUseConnections << "\n";
|
|
if (!resourcePeaks.lastWriteHotSources.empty()) {
|
|
std::cout << " Write hot sources: " << resourcePeaks.lastWriteHotSources << "\n";
|
|
}
|
|
if (hotspotSummary.contains("worst_window")) {
|
|
const auto& window = hotspotSummary["worst_window"];
|
|
std::cout << " Worst latency window: start=" << window.value("start_ms", 0)
|
|
<< "ms end=" << window.value("end_ms", 0)
|
|
<< "ms avg=" << window.value("avg_latency_us", 0.0)
|
|
<< "us samples=" << window.value("sample_count", 0) << "\n";
|
|
}
|
|
std::cout << " Queue drained: " << (drained ? "yes" : "NO")
|
|
<< " UX failure rate: " << std::setprecision(3) << uxFailureRate
|
|
<< " Final active/max: " << finalSnap.activeConnections << "/"
|
|
<< finalSnap.maxConnections << "\n\n";
|
|
printDrainAndIdleProbeSummary(drainObservation, idleProbe);
|
|
std::cout << " Recovery quiesced: " << (recoveryQuiesced ? "yes" : "NO") << "\n";
|
|
printRecoverySummary(recovery);
|
|
if (cliRecoveryAttempted) {
|
|
std::cout << " CLI post-stress recovery: status="
|
|
<< (cliRecoveryStatus.success ? "ok" : "FAIL")
|
|
<< " list=" << (cliRecoveryList.success ? "ok" : "FAIL") << "\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
// Phase 3: deterministic control cycle for growth/shrink validation on a fresh daemon
|
|
// instance so realistic phase-2 idle sessions do not distort shrink behavior.
|
|
harness.stop();
|
|
DaemonHarness controlHarness(benchHarnessOptions(cfg));
|
|
REQUIRE(startHarnessWithRetry(controlHarness, kStartTimeout));
|
|
|
|
ClientConfig controlMonCfg;
|
|
controlMonCfg.socketPath = controlHarness.socketPath();
|
|
controlMonCfg.autoStart = false;
|
|
controlMonCfg.requestTimeout = 10s;
|
|
DaemonClient controlMonitorClient(controlMonCfg);
|
|
|
|
TimeSeriesSampler controlSampler;
|
|
controlSampler.start(controlMonitorClient, 100ms);
|
|
|
|
std::atomic<int> controlAddOps{0};
|
|
std::atomic<int> controlUxOps{0};
|
|
HeldSocketConnections controlHeldConnections;
|
|
const size_t controlHeldCount = kInitialSlotLimit - 2;
|
|
const bool controlHeldConnectionsOpened = controlHeldConnections.open(
|
|
controlHarness.socketPath(), static_cast<int>(controlHeldCount));
|
|
INFO("HeldSocketConnections.open control ramp socket="
|
|
<< controlHarness.socketPath().string() << " requested=" << controlHeldCount
|
|
<< " opened=" << controlHeldConnections.openedCount()
|
|
<< " failed_index=" << controlHeldConnections.lastFailedIndex() << " error='"
|
|
<< controlHeldConnections.lastError() << "'");
|
|
REQUIRE(controlHeldConnectionsOpened);
|
|
|
|
const int controlAddsPerWriter =
|
|
checkedMulToInt(std::max(8, checkedMulToInt(cfg.docsPerClient, 2, kMaxBenchOpsPerClient)),
|
|
cfg.stressRounds, kMaxBenchOpsPerClient);
|
|
const int controlUxOpsPerClient = checkedMulToInt(12, cfg.stressRounds, kMaxBenchOpsPerClient);
|
|
|
|
std::thread controlWriter([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = controlHarness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
for (int i = 0; i < controlAddsPerWriter; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "ux_scale_control_add_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(901, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "ux-scaling", "control"};
|
|
req.collection = "ux_scaling_control";
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
if (res) {
|
|
controlAddOps.fetch_add(1);
|
|
}
|
|
}
|
|
});
|
|
|
|
std::thread controlUx([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = controlHarness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
|
|
for (int i = 0; i < controlUxOpsPerClient; ++i) {
|
|
switch (i % 3) {
|
|
case 0: {
|
|
(void)yams::cli::run_sync(client.status(), 10s);
|
|
break;
|
|
}
|
|
case 1: {
|
|
ListRequest req;
|
|
req.limit = 10;
|
|
(void)yams::cli::run_sync(client.list(req), 10s);
|
|
break;
|
|
}
|
|
default: {
|
|
SearchRequest req;
|
|
req.query = cfg.searchQuery;
|
|
req.searchType = cfg.searchType;
|
|
req.limit = cfg.searchLimit;
|
|
(void)yams::cli::run_sync(client.search(req), 10s);
|
|
break;
|
|
}
|
|
}
|
|
controlUxOps.fetch_add(1);
|
|
}
|
|
});
|
|
|
|
controlWriter.join();
|
|
controlUx.join();
|
|
|
|
const bool controlGrew = waitForSlotLimit(controlMonitorClient, grownSlotTarget, 6s, true);
|
|
controlHeldConnections.closeAll();
|
|
const bool controlReachedLightLoad =
|
|
waitForActiveConnectionsAtMost(controlMonitorClient, controlLightLoadTarget, 12s);
|
|
const bool controlShrank =
|
|
waitForSlotLimit(controlMonitorClient, kInitialSlotLimit, 10s, false);
|
|
controlSampler.stop();
|
|
|
|
const auto controlSamples = controlSampler.getSamples();
|
|
const auto controlSlotSummary = summarizeSlotScaling(controlSamples);
|
|
const auto controlFinalSnap = DaemonSnapshot::capture(controlMonitorClient);
|
|
|
|
std::cout << "=== Deterministic Control Phase ===\n";
|
|
std::cout << " Adds: " << controlAddOps.load() << " UX ops: " << controlUxOps.load() << "\n";
|
|
std::cout << " Slot scaling: initial=" << controlSlotSummary.initialLimit
|
|
<< " peak=" << controlSlotSummary.peakLimit
|
|
<< " final=" << controlSlotSummary.finalLimit
|
|
<< " grow_events=" << controlSlotSummary.growEvents
|
|
<< " shrink_events=" << controlSlotSummary.shrinkEvents
|
|
<< " peak_active=" << controlSlotSummary.peakActiveConnections
|
|
<< " final_active=" << controlFinalSnap.activeConnections
|
|
<< " final_fds=" << controlFinalSnap.processFdCount
|
|
<< " grew=" << (controlGrew ? "yes" : "NO")
|
|
<< " light_load=" << (controlReachedLightLoad ? "yes" : "NO")
|
|
<< " shrank=" << (controlShrank ? "yes" : "NO") << "\n\n";
|
|
|
|
CHECK((addOps.load() + addFails.load()) > 0);
|
|
CHECK(uxOps.load() > 0);
|
|
CHECK((searchStats.count + listStats.count + statusStats.count) > 0);
|
|
CHECK(drained);
|
|
INFO("recoveryQuiesced=" << recoveryQuiesced);
|
|
CHECK(recovery.ok());
|
|
CHECK(cliRecoveryOk);
|
|
CHECK(unacceptableCliFailures == 0);
|
|
CHECK(controlAddOps.load() > 0);
|
|
CHECK(controlUxOps.load() > 0);
|
|
CHECK(controlGrew);
|
|
CHECK(controlSlotSummary.peakLimit >= grownSlotTarget);
|
|
CHECK(controlSlotSummary.growEvents > 0);
|
|
if (controlReachedLightLoad) {
|
|
CHECK(controlShrank);
|
|
CHECK(controlSlotSummary.finalLimit <= kInitialSlotLimit);
|
|
CHECK(controlSlotSummary.shrinkEvents > 0);
|
|
} else {
|
|
WARN("Skipping shrink assertions because control active connections did not reach "
|
|
"the light-load target; final_active="
|
|
<< controlFinalSnap.activeConnections << " target=" << controlLightLoadTarget
|
|
<< " final_limit=" << controlSlotSummary.finalLimit
|
|
<< " final_fd_count=" << controlFinalSnap.processFdCount);
|
|
}
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "socket_scaling_vs_ux_latency"},
|
|
{"num_writer_clients", writerClients},
|
|
{"num_ux_clients", uxClients},
|
|
{"docs_per_writer", writerOpsPerClient},
|
|
{"ux_ops_per_client", uxOpsPerClient},
|
|
{"stress_rounds", cfg.stressRounds},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"search_config", json{{"daemon", json{{"query", cfg.searchQuery},
|
|
{"type", cfg.searchType},
|
|
{"limit", cfg.searchLimit}}},
|
|
{"cli", json{{"query", cfg.cliSearchQuery},
|
|
{"type", cfg.cliSearchType},
|
|
{"limit", cfg.cliSearchLimit}}}}},
|
|
{"add", json{{"ops", addOps.load()},
|
|
{"fails", addFails.load()},
|
|
{"throughput_per_sec", addThroughput},
|
|
{"latency", addStats.toJson()}}},
|
|
{"search", json{{"latency", searchStats.toJson()}}},
|
|
{"list", json{{"latency", listStats.toJson()}}},
|
|
{"status", json{{"latency", statusStats.toJson()}}},
|
|
{"cli", json{{"enabled", cliStressProbesEnabled},
|
|
{"binary", yamsBinary.path ? yamsBinary.path.value().string() : std::string()},
|
|
{"selection", yamsBinary.toJson()},
|
|
{"ops", cliOps.load()},
|
|
{"fails", cliFails.load()},
|
|
{"ops_per_command", cliProbeOpsPerCommand},
|
|
{"failure_buckets", cliFailureBuckets},
|
|
{"failure_modes", cliFailureModes},
|
|
{"unacceptable_failures", unacceptableCliFailures},
|
|
{"failure_samples", cliFailureSamples},
|
|
{"probe_results", cliProbeResultsJson},
|
|
{"post_stress_recovery", json{{"attempted", cliRecoveryAttempted},
|
|
{"ok", cliRecoveryOk},
|
|
{"status_probe", cliRecoveryStatus.toJson()},
|
|
{"list_probe", cliRecoveryList.toJson()}}},
|
|
{"list_latency", cliListStats.toJson()},
|
|
{"search_latency", cliSearchStats.toJson()},
|
|
{"status_latency", cliStatusStats.toJson()}}},
|
|
{"ux", json{{"ops", uxOps.load()},
|
|
{"fails", uxFails.load()},
|
|
{"throughput_per_sec", uxThroughput},
|
|
{"failure_rate", uxFailureRate},
|
|
{"retry_after_count", retryAfterCount.load()},
|
|
{"error_categories", uxErrorCategories},
|
|
{"failure_samples", uxFailureSamples}}},
|
|
{"slot_scaling", json{{"initial_limit", slotSummary.initialLimit},
|
|
{"peak_limit", slotSummary.peakLimit},
|
|
{"final_limit", slotSummary.finalLimit},
|
|
{"observed_growth", observedGrowth},
|
|
{"observed_shrink", observedShrink},
|
|
{"growth_target", grownSlotTarget},
|
|
{"final_floor_target", kInitialSlotLimit},
|
|
{"min_free_slots", slotSummary.minFreeSlots},
|
|
{"peak_active_connections", slotSummary.peakActiveConnections},
|
|
{"peak_utilization", slotSummary.peakUtilization},
|
|
{"grow_events", slotSummary.growEvents},
|
|
{"shrink_events", slotSummary.shrinkEvents},
|
|
{"retry_after_samples", slotSummary.retryAfterSamples}}},
|
|
{"deterministic_control",
|
|
json{{"add_ops", controlAddOps.load()},
|
|
{"ux_ops", controlUxOps.load()},
|
|
{"grew_to_target", controlGrew},
|
|
{"reached_light_load", controlReachedLightLoad},
|
|
{"shrank_to_floor", controlShrank},
|
|
{"slot_scaling",
|
|
json{{"initial_limit", controlSlotSummary.initialLimit},
|
|
{"peak_limit", controlSlotSummary.peakLimit},
|
|
{"final_limit", controlSlotSummary.finalLimit},
|
|
{"min_free_slots", controlSlotSummary.minFreeSlots},
|
|
{"peak_active_connections", controlSlotSummary.peakActiveConnections},
|
|
{"peak_utilization", controlSlotSummary.peakUtilization},
|
|
{"grow_events", controlSlotSummary.growEvents},
|
|
{"shrink_events", controlSlotSummary.shrinkEvents},
|
|
{"retry_after_samples", controlSlotSummary.retryAfterSamples},
|
|
{"final_active_connections", controlFinalSnap.activeConnections}}}}},
|
|
{"resource_peaks", resourcePeaks.toJson()},
|
|
{"hotspots", hotspotSummary},
|
|
{"op_stage_latency", opStageSummary},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"drained", drained},
|
|
{"recovery_quiesced", recoveryQuiesced},
|
|
{"drain_metrics", drainObservation.toJson()},
|
|
{"idle_probe", idleProbe.toJson()},
|
|
{"recovery", recovery.toJson()},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"time_series_samples", samples.size()},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: scaling curve", "[!benchmark][multi-client][scaling]") {
|
|
// Run the same workload at increasing powers-of-two clients to find scaling limits.
|
|
auto baseCfg = BenchConfig::fromEnv();
|
|
std::vector<int> clientCounts;
|
|
for (std::int64_t n = 1; n <= baseCfg.scalingMaxClients;) {
|
|
clientCounts.push_back(static_cast<int>(n));
|
|
n *= 2;
|
|
}
|
|
if (!clientCounts.empty() && clientCounts.back() != baseCfg.scalingMaxClients) {
|
|
clientCounts.push_back(baseCfg.scalingMaxClients);
|
|
}
|
|
|
|
json scalingResults = json::array();
|
|
|
|
for (int numClients : clientCounts) {
|
|
std::cout << "\n--- Scaling test: " << numClients << " client(s) ---\n";
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(startHarnessWithRetry(harness, kStartTimeout));
|
|
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
std::atomic<int> totalDocs{0};
|
|
std::atomic<int> totalFail{0};
|
|
std::vector<ClientResult> results(numClients);
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
std::vector<std::thread> threads;
|
|
for (int c = 0; c < numClients; ++c) {
|
|
threads.emplace_back([&, c]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
auto start = std::chrono::steady_clock::now();
|
|
for (int i = 0; i < baseCfg.docsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "scale_c" + std::to_string(c) + "_d" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(c, i, baseCfg.docSizeBytes);
|
|
req.tags = {"bench", "scaling"};
|
|
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
if (res) {
|
|
results[c].docsIngested++;
|
|
totalDocs.fetch_add(1);
|
|
} else {
|
|
results[c].failures++;
|
|
totalFail.fetch_add(1);
|
|
}
|
|
}
|
|
auto end = std::chrono::steady_clock::now();
|
|
results[c].elapsedSeconds = std::chrono::duration<double>(end - start).count();
|
|
results[c].addThroughputDocsPerSec =
|
|
results[c].docsIngested / results[c].elapsedSeconds;
|
|
});
|
|
}
|
|
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
double aggThroughput = static_cast<double>(totalDocs.load()) / globalElapsed;
|
|
|
|
bool drained = waitForDrain(monitorClient, std::chrono::seconds(baseCfg.drainTimeoutSecs));
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
|
|
// Per-client throughput stats
|
|
std::vector<double> perClient;
|
|
for (auto& r : results)
|
|
perClient.push_back(r.addThroughputDocsPerSec);
|
|
|
|
double meanPerClient = std::accumulate(perClient.begin(), perClient.end(), 0.0) /
|
|
static_cast<double>(perClient.size());
|
|
|
|
std::cout << " Clients: " << numClients << " Aggregate: " << std::fixed
|
|
<< std::setprecision(1) << aggThroughput << " docs/s"
|
|
<< " Per-client mean: " << meanPerClient << " docs/s"
|
|
<< " Failures: " << totalFail.load() << " Memory: " << finalSnap.memoryUsageMb
|
|
<< " MB"
|
|
<< " Drained: " << (drained ? "yes" : "NO") << "\n";
|
|
|
|
CHECK(totalDocs.load() == checkedMulToInt(numClients, baseCfg.docsPerClient));
|
|
|
|
scalingResults.push_back(json{
|
|
{"num_clients", numClients},
|
|
{"aggregate_throughput", aggThroughput},
|
|
{"per_client_mean_throughput", meanPerClient},
|
|
{"total_docs", totalDocs.load()},
|
|
{"total_failures", totalFail.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"memory_mb", finalSnap.memoryUsageMb},
|
|
{"drained", drained},
|
|
});
|
|
|
|
// Harness destructor will stop the daemon
|
|
}
|
|
|
|
// Print scaling summary
|
|
std::cout << "\n=== Scaling Curve Summary ===\n";
|
|
std::cout << std::setw(10) << "Clients" << std::setw(18) << "Agg docs/s" << std::setw(18)
|
|
<< "Per-client docs/s" << std::setw(12) << "Efficiency"
|
|
<< "\n";
|
|
double baselineThroughput = 0;
|
|
for (auto& r : scalingResults) {
|
|
double agg = r["aggregate_throughput"];
|
|
double perClient = r["per_client_mean_throughput"];
|
|
int n = r["num_clients"];
|
|
if (n == 1)
|
|
baselineThroughput = agg;
|
|
double efficiency =
|
|
(baselineThroughput > 0) ? (agg / (baselineThroughput * n)) * 100.0 : 0.0;
|
|
std::cout << std::setw(10) << n << std::setw(18) << std::fixed << std::setprecision(1)
|
|
<< agg << std::setw(18) << perClient << std::setw(11) << std::setprecision(1)
|
|
<< efficiency << "%\n";
|
|
}
|
|
std::cout << std::endl;
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "scaling_curve"},
|
|
{"docs_per_client", baseCfg.docsPerClient},
|
|
{"doc_size_bytes", baseCfg.docSizeBytes},
|
|
{"scaling_results", scalingResults},
|
|
{"tuning_profile", baseCfg.tuningProfile},
|
|
};
|
|
emitJsonl(baseCfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: 16-client concurrent mixed ops",
|
|
"[!benchmark][multi-client][mixed-16]") {
|
|
// 16 clients performing list, search, status, and get simultaneously while
|
|
// background writers are ingesting. Measures per-operation-type latency and
|
|
// throughput under realistic concurrent access patterns.
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
const int kWriterClients = cfg.mixedWriterClients;
|
|
const int kSearchClients = cfg.mixedSearchClients;
|
|
const int kListClients = cfg.mixedListClients;
|
|
const int kStatusClients = cfg.mixedStatusClients;
|
|
const int kTotalClients = checkedAddToInt(
|
|
{kWriterClients, kSearchClients, kListClients, kStatusClients}, kMaxBenchClients * 4);
|
|
|
|
// Each reader client runs for a fixed number of operations
|
|
const int kOpsPerReader = cfg.mixedOpsPerReader;
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(harness.start(kStartTimeout));
|
|
|
|
// Pre-seed 100 docs so search/list have data from the start
|
|
std::vector<std::string> knownHashes;
|
|
{
|
|
ClientConfig warmCfg;
|
|
warmCfg.socketPath = harness.socketPath();
|
|
warmCfg.autoStart = false;
|
|
warmCfg.requestTimeout = 30s;
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
std::cout << " Pre-seeding 100 warmup docs...\n";
|
|
for (int i = 0; i < 100; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "mixed16_warmup_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(99, i, cfg.docSizeBytes);
|
|
req.tags = {"warmup", "bench"};
|
|
auto res = yams::cli::run_sync(warmClient.streamingAddDocument(req), 30s);
|
|
if (res && !res.value().hash.empty()) {
|
|
knownHashes.push_back(res.value().hash);
|
|
}
|
|
}
|
|
(void)waitForDrain(warmClient, 60s);
|
|
std::this_thread::sleep_for(1s);
|
|
std::cout << " Warmup complete (" << knownHashes.size() << " hashes)\n";
|
|
}
|
|
|
|
// Monitor client
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, 500ms);
|
|
|
|
// Per-thread latency collectors
|
|
std::mutex searchLatMutex, listLatMutex, statusLatMutex, getLatMutex, addLatMutex;
|
|
std::vector<int64_t> searchLatencies, listLatencies, statusLatencies, getLatencies,
|
|
addLatencies;
|
|
std::map<std::string, int> mixedErrorCategories;
|
|
std::map<std::string, std::string> mixedFailureSamples;
|
|
|
|
std::atomic<int> searchOps{0}, listOps{0}, statusOps{0}, getOps{0}, addOps{0};
|
|
std::atomic<int> searchFails{0}, listFails{0}, statusFails{0}, getFails{0}, addFails{0};
|
|
|
|
// Barrier: all threads start at the same instant
|
|
std::atomic<bool> go{false};
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(kTotalClients);
|
|
|
|
// --- Writer threads (4): continuous ingestion ---
|
|
for (int w = 0; w < kWriterClients; ++w) {
|
|
threads.emplace_back([&, w]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
std::map<std::string, int> localErrorCategories;
|
|
std::map<std::string, std::string> localFailureSamples;
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "mixed16_w" + std::to_string(w) + "_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(w, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "mixed16", "writer-" + std::to_string(w)};
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
addOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(addLatMutex);
|
|
addLatencies.push_back(latUs);
|
|
} else {
|
|
addFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(res.error().message);
|
|
const auto key = std::string("add:") + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!res.error().message.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = res.error().message;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!localErrorCategories.empty()) {
|
|
std::lock_guard<std::mutex> lk(addLatMutex);
|
|
for (const auto& [key, count] : localErrorCategories) {
|
|
mixedErrorCategories[key] += count;
|
|
}
|
|
for (const auto& [key, sample] : localFailureSamples) {
|
|
if (!mixedFailureSamples.contains(key)) {
|
|
mixedFailureSamples[key] = sample;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Search threads (4): continuous search queries ---
|
|
for (int s = 0; s < kSearchClients; ++s) {
|
|
threads.emplace_back([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
std::map<std::string, int> localErrorCategories;
|
|
std::map<std::string, std::string> localFailureSamples;
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
static const char* queries[] = {
|
|
"architecture performance tuning",
|
|
"ingestion pipeline extraction",
|
|
"resource governance memory pressure",
|
|
"thread pool concurrency limits",
|
|
"IPC transport protocol",
|
|
};
|
|
|
|
for (int i = 0; i < kOpsPerReader; ++i) {
|
|
SearchRequest req;
|
|
req.query = queries[i % 5];
|
|
req.limit = 10;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.search(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
searchOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(searchLatMutex);
|
|
searchLatencies.push_back(latUs);
|
|
} else {
|
|
searchFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(res.error().message);
|
|
const auto key = std::string("search:") + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!res.error().message.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = res.error().message;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!localErrorCategories.empty()) {
|
|
std::lock_guard<std::mutex> lk(searchLatMutex);
|
|
for (const auto& [key, count] : localErrorCategories) {
|
|
mixedErrorCategories[key] += count;
|
|
}
|
|
for (const auto& [key, sample] : localFailureSamples) {
|
|
if (!mixedFailureSamples.contains(key)) {
|
|
mixedFailureSamples[key] = sample;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- List threads (4): continuous list queries with varying filters ---
|
|
for (int l = 0; l < kListClients; ++l) {
|
|
threads.emplace_back([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
std::map<std::string, int> localErrorCategories;
|
|
std::map<std::string, std::string> localFailureSamples;
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerReader; ++i) {
|
|
ListRequest req;
|
|
req.limit = (i % 3 == 0) ? 5 : (i % 3 == 1) ? 20 : 50;
|
|
// Alternate between filtered and unfiltered
|
|
if (i % 4 == 0) {
|
|
req.tags = {"warmup"};
|
|
}
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.list(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
listOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(listLatMutex);
|
|
listLatencies.push_back(latUs);
|
|
} else {
|
|
listFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(res.error().message);
|
|
const auto key = std::string("list:") + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!res.error().message.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = res.error().message;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!localErrorCategories.empty()) {
|
|
std::lock_guard<std::mutex> lk(listLatMutex);
|
|
for (const auto& [key, count] : localErrorCategories) {
|
|
mixedErrorCategories[key] += count;
|
|
}
|
|
for (const auto& [key, sample] : localFailureSamples) {
|
|
if (!mixedFailureSamples.contains(key)) {
|
|
mixedFailureSamples[key] = sample;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Status threads (4): continuous status + get queries ---
|
|
for (int t = 0; t < kStatusClients; ++t) {
|
|
threads.emplace_back([&, t]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
std::mt19937 rng(static_cast<unsigned>(t * 997 + 13));
|
|
std::map<std::string, int> localErrorCategories;
|
|
std::map<std::string, std::string> localFailureSamples;
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerReader; ++i) {
|
|
if (i % 3 == 0) {
|
|
// Status query
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.status(), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
statusOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(statusLatMutex);
|
|
statusLatencies.push_back(latUs);
|
|
} else {
|
|
statusFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(res.error().message);
|
|
const auto key = std::string("status:") + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!res.error().message.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = res.error().message;
|
|
}
|
|
}
|
|
} else {
|
|
// Get (metadata) by hash
|
|
std::string hash;
|
|
{
|
|
if (!knownHashes.empty()) {
|
|
std::uniform_int_distribution<std::size_t> dist(0,
|
|
knownHashes.size() - 1);
|
|
hash = knownHashes[dist(rng)];
|
|
}
|
|
}
|
|
if (hash.empty()) {
|
|
statusOps.fetch_add(1); // count as no-op
|
|
continue;
|
|
}
|
|
|
|
GetRequest req;
|
|
req.hash = hash;
|
|
req.metadataOnly = true;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.get(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
getOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(getLatMutex);
|
|
getLatencies.push_back(latUs);
|
|
} else {
|
|
getFails.fetch_add(1);
|
|
const auto bucket = classifyFailureMessage(res.error().message);
|
|
const auto key = std::string("get:") + bucket;
|
|
localErrorCategories[key] += 1;
|
|
if (!res.error().message.empty() && !localFailureSamples.contains(key)) {
|
|
localFailureSamples[key] = res.error().message;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!localErrorCategories.empty()) {
|
|
std::lock_guard<std::mutex> lk(statusLatMutex);
|
|
for (const auto& [key, count] : localErrorCategories) {
|
|
mixedErrorCategories[key] += count;
|
|
}
|
|
for (const auto& [key, sample] : localFailureSamples) {
|
|
if (!mixedFailureSamples.contains(key)) {
|
|
mixedFailureSamples[key] = sample;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Release all threads simultaneously
|
|
go.store(true);
|
|
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
sampler.stop();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
// Drain
|
|
bool drained = waitForDrain(monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs));
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
auto resourcePeaks = summarizeResourcePeaks(sampler.getSamples());
|
|
|
|
// Compute stats
|
|
auto searchStats = PercentileStats::compute(searchLatencies);
|
|
auto listStats = PercentileStats::compute(listLatencies);
|
|
auto statusStats = PercentileStats::compute(statusLatencies);
|
|
auto getStats = PercentileStats::compute(getLatencies);
|
|
auto addStats = PercentileStats::compute(addLatencies);
|
|
|
|
int totalOps =
|
|
searchOps.load() + listOps.load() + statusOps.load() + getOps.load() + addOps.load();
|
|
int totalFails = searchFails.load() + listFails.load() + statusFails.load() + getFails.load() +
|
|
addFails.load();
|
|
double opsPerSec = static_cast<double>(totalOps) / globalElapsed;
|
|
double failRate = (totalOps > 0) ? (static_cast<double>(totalFails) / totalOps) : 0.0;
|
|
const std::string stressTier = !drained ? "failed" : (totalFails == 0 ? "clean" : "degraded");
|
|
|
|
// Print summary
|
|
std::cout << "\n=== " << kTotalClients << "-Client Concurrent Mixed Ops ===\n";
|
|
std::cout << " Layout: " << kWriterClients << " writers + " << kSearchClients << " searchers"
|
|
<< " + " << kListClients << " listers + " << kStatusClients << " status/get\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
std::cout << " Total ops: " << totalOps << " (" << std::setprecision(0) << opsPerSec
|
|
<< " ops/s)"
|
|
<< " Failures: " << totalFails << " (rate=" << std::setprecision(4) << failRate
|
|
<< ")\n\n";
|
|
|
|
std::cout << " Op breakdown:\n";
|
|
std::cout << " Add: " << addOps.load() << " ok, " << addFails.load() << " fail\n";
|
|
std::cout << " Search: " << searchOps.load() << " ok, " << searchFails.load() << " fail\n";
|
|
std::cout << " List: " << listOps.load() << " ok, " << listFails.load() << " fail\n";
|
|
std::cout << " Status: " << statusOps.load() << " ok, " << statusFails.load() << " fail\n";
|
|
std::cout << " Get: " << getOps.load() << " ok, " << getFails.load() << " fail\n\n";
|
|
if (!mixedErrorCategories.empty()) {
|
|
std::cout << " Failure categories:";
|
|
for (const auto& [key, count] : mixedErrorCategories) {
|
|
std::cout << " " << key << "=" << count;
|
|
}
|
|
std::cout << "\n\n";
|
|
}
|
|
|
|
printPercentiles("Add latency ", addStats);
|
|
printPercentiles("Search latency", searchStats);
|
|
printPercentiles("List latency ", listStats);
|
|
printPercentiles("Status latency", statusStats);
|
|
printPercentiles("Get latency ", getStats);
|
|
|
|
std::cout << "\n Queue drained: " << (drained ? "yes" : "NO") << "\n";
|
|
std::cout << " Peak DB read/write waiting: " << resourcePeaks.peakDbReadPoolWaiting << "/"
|
|
<< resourcePeaks.peakDbWritePoolWaiting << "\n";
|
|
std::cout << " Final docs_total: " << finalSnap.documentsTotal << "\n";
|
|
std::cout << " Final memory: " << finalSnap.memoryUsageMb << " MB\n\n";
|
|
|
|
// Key assertions: all op types must make progress
|
|
CHECK(addOps.load() > 0);
|
|
CHECK(searchOps.load() > 0);
|
|
CHECK(listOps.load() > 0);
|
|
CHECK(statusOps.load() > 0);
|
|
CHECK(getOps.load() > 0);
|
|
CHECK(drained);
|
|
CHECK(failRate <= cfg.mixedMaxFailRate);
|
|
|
|
// Emit JSONL
|
|
json timeSeries = json::array();
|
|
for (auto& s : sampler.getSamples()) {
|
|
timeSeries.push_back(json{{"elapsed_ms", s.elapsedMs}, {"snapshot", s.snap.toJson()}});
|
|
}
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "16_client_concurrent_mixed_ops"},
|
|
{"layout", json{{"writers", kWriterClients},
|
|
{"searchers", kSearchClients},
|
|
{"listers", kListClients},
|
|
{"status_get", kStatusClients}}},
|
|
{"ops_per_reader", kOpsPerReader},
|
|
{"docs_per_writer", cfg.docsPerClient},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"total_ops", totalOps},
|
|
{"total_failures", totalFails},
|
|
{"fail_rate", failRate},
|
|
{"stress_tier", stressTier},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"ops_per_sec", opsPerSec},
|
|
{"add",
|
|
json{{"ops", addOps.load()}, {"fails", addFails.load()}, {"latency", addStats.toJson()}}},
|
|
{"search", json{{"ops", searchOps.load()},
|
|
{"fails", searchFails.load()},
|
|
{"latency", searchStats.toJson()}}},
|
|
{"list", json{{"ops", listOps.load()},
|
|
{"fails", listFails.load()},
|
|
{"latency", listStats.toJson()}}},
|
|
{"status", json{{"ops", statusOps.load()},
|
|
{"fails", statusFails.load()},
|
|
{"latency", statusStats.toJson()}}},
|
|
{"get",
|
|
json{{"ops", getOps.load()}, {"fails", getFails.load()}, {"latency", getStats.toJson()}}},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"resource_peaks", resourcePeaks.toJson()},
|
|
{"error_categories", mixedErrorCategories},
|
|
{"failure_samples", mixedFailureSamples},
|
|
{"time_series", timeSeries},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
{"drained", drained},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
// =============================================================================
|
|
// EXISTING CORPUS BENCHMARK
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Multi-client ingestion: large corpus reads",
|
|
"[!benchmark][multi-client][large-corpus]") {
|
|
// Instrumented benchmark against an existing YAMS corpus (e.g. /Volumes/picaso/yams
|
|
// with 48k+ docs). Does NOT ingest — hammers search/list/get/status against the
|
|
// real corpus to measure read-path performance. Outputs:
|
|
// - Per-op latency traces (every operation recorded)
|
|
// - Live progress with rolling throughput
|
|
// - Memory watermarks (peak RSS, delta over time)
|
|
// - Error classification breakdown
|
|
// - Detailed JSONL with all latency traces for post-analysis
|
|
//
|
|
// Env overrides:
|
|
// YAMS_BENCH_DATA_DIR - required: existing corpus path
|
|
// YAMS_BENCH_SEARCH_CLIENTS - search thread count (default: 4)
|
|
// YAMS_BENCH_LIST_CLIENTS - list thread count (default: 4)
|
|
// YAMS_BENCH_GREP_CLIENTS - grep thread count (default: 2)
|
|
// YAMS_BENCH_STATUS_GET_CLIENTS - status/get thread count (default: 4)
|
|
// YAMS_BENCH_OPS_PER_CLIENT - ops per thread (default: 50)
|
|
// YAMS_BENCH_OP_TIMEOUT_S - per-op timeout in seconds (default: 60)
|
|
// YAMS_BENCH_STATUS_EVERY_N - status cadence in status/get threads (default: 4)
|
|
// YAMS_BENCH_CAT_EVERY_N - cat cadence among non-status status/get ops
|
|
// (default: 3)
|
|
// YAMS_BENCH_LIST_INFLIGHT_CAP - max concurrent list ops (0 = unlimited, default: 0)
|
|
// YAMS_BENCH_USAGE_PROFILE - mixed|extension_direct|external_agent_churn
|
|
// (default: mixed)
|
|
// YAMS_BENCH_MCP_SESSION_CHURN_EVERY_N - MCP only; every Nth request uses a
|
|
// transient MCP session (default: 6 for
|
|
// external_agent_churn, otherwise 0)
|
|
auto cfg = BenchConfig::fromEnv();
|
|
if (!cfg.dataDir) {
|
|
SKIP("Set YAMS_BENCH_DATA_DIR to an existing YAMS corpus to run this benchmark");
|
|
}
|
|
|
|
const bool extensionLikeProfile =
|
|
(cfg.usageProfile == "extension_direct" || cfg.usageProfile == "vscode_blackboard");
|
|
const bool externalAgentChurnProfile =
|
|
(cfg.usageProfile == "external_agent_churn" || cfg.usageProfile == "agent_churn");
|
|
|
|
// --- Configurable layout ---
|
|
const int defaultSearchClients = externalAgentChurnProfile ? 4 : (extensionLikeProfile ? 6 : 4);
|
|
const int defaultListClients = externalAgentChurnProfile ? 4 : (extensionLikeProfile ? 6 : 4);
|
|
const int defaultGrepClients = externalAgentChurnProfile ? 1 : (extensionLikeProfile ? 1 : 2);
|
|
const int defaultStatusGetClients =
|
|
externalAgentChurnProfile ? 12 : (extensionLikeProfile ? 10 : 4);
|
|
const int defaultOpsPerClient =
|
|
externalAgentChurnProfile ? 90 : (extensionLikeProfile ? 80 : 50);
|
|
const int defaultStatusEveryN = externalAgentChurnProfile ? 2 : (extensionLikeProfile ? 8 : 4);
|
|
const int defaultCatEveryN = externalAgentChurnProfile ? 14 : (extensionLikeProfile ? 10 : 3);
|
|
const int defaultListInflightCap =
|
|
externalAgentChurnProfile ? 1 : (extensionLikeProfile ? 2 : 0);
|
|
|
|
const int kSearchClients =
|
|
parseEnvIntClamped("YAMS_BENCH_SEARCH_CLIENTS", defaultSearchClients, 0, kMaxBenchClients);
|
|
const int kListClients =
|
|
parseEnvIntClamped("YAMS_BENCH_LIST_CLIENTS", defaultListClients, 0, kMaxBenchClients);
|
|
const int kGrepClients =
|
|
parseEnvIntClamped("YAMS_BENCH_GREP_CLIENTS", defaultGrepClients, 0, kMaxBenchClients);
|
|
const int kStatusGetClients = parseEnvIntClamped("YAMS_BENCH_STATUS_GET_CLIENTS",
|
|
defaultStatusGetClients, 0, kMaxBenchClients);
|
|
const int kTotalClients = checkedAddToInt(
|
|
{kSearchClients, kListClients, kGrepClients, kStatusGetClients}, kMaxBenchClients * 4);
|
|
const int kOpsPerClient = parseEnvIntClamped("YAMS_BENCH_OPS_PER_CLIENT", defaultOpsPerClient,
|
|
1, kMaxBenchOpsPerClient);
|
|
const int kOpTimeoutS = parseEnvIntClamped("YAMS_BENCH_OP_TIMEOUT_S", 60, 1, kMaxBenchSeconds);
|
|
const auto kOpTimeout = std::chrono::seconds(kOpTimeoutS);
|
|
const int kStatusEveryN = parseEnvIntClamped("YAMS_BENCH_STATUS_EVERY_N", defaultStatusEveryN,
|
|
1, kMaxBenchOpsPerClient);
|
|
const int kCatEveryN =
|
|
parseEnvIntClamped("YAMS_BENCH_CAT_EVERY_N", defaultCatEveryN, 1, kMaxBenchOpsPerClient);
|
|
const int kListInflightCap = parseEnvIntClamped("YAMS_BENCH_LIST_INFLIGHT_CAP",
|
|
defaultListInflightCap, 0, kMaxBenchClients);
|
|
const int kDiscoverPageSize =
|
|
parseEnvIntClamped("YAMS_BENCH_DISCOVER_PAGE_SIZE", 500, 1, kMaxBenchPageSize);
|
|
const int kDiscoverHashLimit =
|
|
parseEnvIntClamped("YAMS_BENCH_DISCOVER_HASH_LIMIT", 5000, 1, kMaxBenchHashLimit);
|
|
const int kHotspotWindowMs =
|
|
parseEnvIntClamped("YAMS_BENCH_HOTSPOT_WINDOW_MS", 1000, 1, kMaxBenchMilliseconds);
|
|
const bool useMcpPath = cfg.useMcpPath;
|
|
const bool useSessionGrep = parseEnvIntClamped("YAMS_BENCH_GREP_USE_SESSION", 0, 0, 1) != 0;
|
|
const int defaultMcpSessionChurnEveryN = (externalAgentChurnProfile && useMcpPath) ? 6 : 0;
|
|
const int kMcpSessionChurnEveryN =
|
|
parseEnvIntClamped("YAMS_BENCH_MCP_SESSION_CHURN_EVERY_N", defaultMcpSessionChurnEveryN, 0,
|
|
kMaxBenchOpsPerClient);
|
|
const size_t mcpPoolSize = static_cast<size_t>(parseEnvIntClamped(
|
|
"YAMS_BENCH_MCP_POOL_SIZE", std::max(1, kTotalClients), 1, kMaxBenchClients * 4));
|
|
|
|
std::cout << "\n=== Large Corpus Read Benchmark (instrumented) ===\n";
|
|
std::cout << " Data dir: " << cfg.dataDir->string() << "\n";
|
|
std::cout << " Layout: " << kSearchClients << " search + " << kListClients
|
|
<< " list + " << kGrepClients << " grep + " << kStatusGetClients
|
|
<< " status/get/cat = " << kTotalClients << " threads\n";
|
|
std::cout << " Ops/thread: " << kOpsPerClient << "\n";
|
|
std::cout << " Op timeout: " << kOpTimeoutS << "s\n";
|
|
std::cout << " Status every: " << kStatusEveryN << " op(s) on status/get/cat threads\n";
|
|
std::cout << " Cat every: " << kCatEveryN
|
|
<< " non-status op(s) on status/get/cat threads\n";
|
|
std::cout << " List cap: "
|
|
<< (kListInflightCap > 0 ? std::to_string(kListInflightCap) : "unlimited")
|
|
<< " in-flight\n";
|
|
std::cout << " Discover page: " << kDiscoverPageSize << " (limit " << kDiscoverHashLimit
|
|
<< ")\n";
|
|
std::cout << " Hotspot window: " << kHotspotWindowMs << "ms\n";
|
|
std::cout << " Usage profile: " << cfg.usageProfile << "\n";
|
|
std::cout << " Transport path: " << (useMcpPath ? "mcp(query-or-direct-tools)" : "daemon-ipc")
|
|
<< "\n";
|
|
if (useMcpPath) {
|
|
std::cout << " MCP pool size: " << mcpPoolSize << "\n";
|
|
std::cout << " MCP churn: every "
|
|
<< (kMcpSessionChurnEveryN > 0 ? std::to_string(kMcpSessionChurnEveryN)
|
|
: std::string("disabled"))
|
|
<< " request(s) uses a fresh MCP session\n";
|
|
}
|
|
if (kGrepClients > 0) {
|
|
std::cout << " Grep mode: "
|
|
<< (useSessionGrep ? "session hot path" : "global cold path") << "\n";
|
|
}
|
|
|
|
// --- Server-side timeout configuration ---
|
|
// Stay within TuneAdvisor range clamps:
|
|
// ipcTimeoutMs: [500, 600000]
|
|
// streamChunkTimeoutMs: [1000, 600000]
|
|
// searchBuildTimeout: unclamped
|
|
std::string timeoutStr =
|
|
std::to_string(std::min(checkedMulToInt(kOpTimeoutS, 2000, 600000), 600000));
|
|
std::vector<yams::test::ScopedEnvVar> timeoutEnvironment;
|
|
if (!yams::config::getenv_nonempty("YAMS_SEARCH_BUILD_TIMEOUT_MS"))
|
|
timeoutEnvironment.emplace_back("YAMS_SEARCH_BUILD_TIMEOUT_MS", std::string{"120000"});
|
|
if (!yams::config::getenv_nonempty("YAMS_IPC_TIMEOUT_MS"))
|
|
timeoutEnvironment.emplace_back("YAMS_IPC_TIMEOUT_MS", timeoutStr);
|
|
if (!yams::config::getenv_nonempty("YAMS_STREAM_CHUNK_TIMEOUT_MS"))
|
|
timeoutEnvironment.emplace_back("YAMS_STREAM_CHUNK_TIMEOUT_MS", timeoutStr);
|
|
|
|
const auto ipcTimeout = yams::config::getenv_nonempty("YAMS_IPC_TIMEOUT_MS");
|
|
const auto streamTimeout = yams::config::getenv_nonempty("YAMS_STREAM_CHUNK_TIMEOUT_MS");
|
|
std::cout << " IPC timeout: " << ipcTimeout.value_or("default") << " ms\n";
|
|
std::cout << " Stream timeout: " << streamTimeout.value_or("default") << " ms\n";
|
|
|
|
// --- Start daemon ---
|
|
auto opts = benchHarnessOptions(cfg);
|
|
DaemonHarness harness(opts);
|
|
REQUIRE(harness.start(std::chrono::seconds(300)));
|
|
std::this_thread::sleep_for(3s); // let async init settle
|
|
|
|
// Helper: create a client config with all timeouts aligned to kOpTimeout.
|
|
// The default headerTimeout (30s) was causing cascading 30s stalls because
|
|
// the client would time out waiting for the first response frame header.
|
|
auto makeClientConfig = [&](std::chrono::milliseconds timeout = std::chrono::milliseconds{0}) {
|
|
ClientConfig c;
|
|
c.socketPath = harness.socketPath();
|
|
c.autoStart = false;
|
|
auto t = timeout.count() > 0 ? timeout : kOpTimeout;
|
|
c.requestTimeout = t;
|
|
c.headerTimeout = t;
|
|
c.bodyTimeout = t;
|
|
return c;
|
|
};
|
|
|
|
// --- Discover corpus ---
|
|
auto discoverCfg = makeClientConfig();
|
|
DaemonClient discoverClient(discoverCfg);
|
|
std::shared_ptr<MCPPipelineClientPool> sharedMcpPool;
|
|
if (useMcpPath) {
|
|
sharedMcpPool = std::make_shared<MCPPipelineClientPool>(harness.socketPath(), mcpPoolSize);
|
|
}
|
|
|
|
std::atomic<uint64_t> mcpQueryCalls{0};
|
|
std::atomic<uint64_t> mcpTransientSessionCalls{0};
|
|
auto queryMcpStepForSlot = [&](size_t slot, const std::string& op,
|
|
const nlohmann::json& params) -> yams::Result<nlohmann::json> {
|
|
if (!sharedMcpPool) {
|
|
return yams::Error{yams::ErrorCode::InvalidState,
|
|
"MCP pool is not initialized for MCP benchmark path"};
|
|
}
|
|
const auto callNumber =
|
|
mcpQueryCalls.fetch_add(1, std::memory_order_relaxed) + static_cast<uint64_t>(1);
|
|
if (kMcpSessionChurnEveryN <= 0) {
|
|
return sharedMcpPool->queryStepForSlot(slot, op, params);
|
|
}
|
|
|
|
const bool useTransient = (callNumber % static_cast<uint64_t>(kMcpSessionChurnEveryN) == 0);
|
|
if (!useTransient) {
|
|
return sharedMcpPool->queryStepForSlot(slot, op, params);
|
|
}
|
|
|
|
mcpTransientSessionCalls.fetch_add(1, std::memory_order_relaxed);
|
|
try {
|
|
MCPPipelineClient transientClient;
|
|
return transientClient.queryStep(op, params);
|
|
} catch (const std::exception& e) {
|
|
return yams::Error{yams::ErrorCode::InvalidState,
|
|
std::string("Transient MCP session failed: ") + e.what()};
|
|
}
|
|
};
|
|
|
|
auto setupBenchmarkSession = [&](const std::string& sessionName) -> bool {
|
|
// Large-corpus grep benchmarks use sessions to exercise the hot path, not to scope
|
|
// results to the YAMS data directory itself. Adding a watched selector rooted at the
|
|
// storage dir narrows MCP grep to internal corpus files and stops matching the direct
|
|
// daemon hot-path workload.
|
|
if (useMcpPath) {
|
|
auto startRes = queryMcpStepForSlot(0, "session_start",
|
|
{{"name", sessionName},
|
|
{"description", "large corpus benchmark session"},
|
|
{"warm", false},
|
|
{"limit", 0}});
|
|
if (!startRes) {
|
|
WARN("session_start failed: " << startRes.error().message);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
yams::test::ScopedEnvVar socketEnvironment{"YAMS_DAEMON_SOCKET_PATH",
|
|
harness.socketPath().string()};
|
|
yams::test::ScopedEnvVar autoStartEnvironment{"YAMS_CLI_DISABLE_DAEMON_AUTOSTART",
|
|
std::string{"1"}};
|
|
MCPPipelineClient sessionClient;
|
|
auto startRes = sessionClient.queryStep("session_start",
|
|
{{"name", sessionName},
|
|
{"description", "large corpus benchmark session"},
|
|
{"warm", false},
|
|
{"limit", 0}});
|
|
if (!startRes) {
|
|
WARN("session_start failed: " << startRes.error().message);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
std::vector<std::string> knownHashes;
|
|
{
|
|
auto discoverDirectPage = [&](std::int64_t offset, int& itemsInPage) -> bool {
|
|
if (offset > std::numeric_limits<int>::max()) {
|
|
WARN("Discovery list offset exceeds ListRequest range: " << offset);
|
|
return false;
|
|
}
|
|
ListRequest listReq;
|
|
listReq.limit = kDiscoverPageSize;
|
|
listReq.offset = static_cast<int>(offset);
|
|
auto listRes = yams::cli::run_sync(discoverClient.list(listReq), kOpTimeout);
|
|
if (!listRes) {
|
|
WARN("Discovery list failed at offset " << offset << ": "
|
|
<< listRes.error().message);
|
|
return false;
|
|
}
|
|
for (auto& item : listRes.value().items) {
|
|
if (!item.hash.empty()) {
|
|
knownHashes.push_back(item.hash);
|
|
++itemsInPage;
|
|
if (static_cast<int>(knownHashes.size()) >= kDiscoverHashLimit) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
bool useDirectDiscoveryFallback = false;
|
|
bool done = false;
|
|
for (std::int64_t offset = 0;
|
|
!done && static_cast<int>(knownHashes.size()) < kDiscoverHashLimit;
|
|
offset += static_cast<std::int64_t>(kDiscoverPageSize)) {
|
|
int itemsInPage = 0;
|
|
if (useMcpPath && !useDirectDiscoveryFallback) {
|
|
auto listRes = queryMcpStepForSlot(
|
|
0, "list",
|
|
{{"limit", kDiscoverPageSize}, {"offset", offset}, {"use_session", false}});
|
|
if (!listRes) {
|
|
WARN("Discovery list failed at offset " << offset << ": "
|
|
<< listRes.error().message);
|
|
break;
|
|
}
|
|
auto collectHashes = [&](const nlohmann::json& arr) {
|
|
if (!arr.is_array()) {
|
|
return;
|
|
}
|
|
for (const auto& item : arr) {
|
|
if (!item.is_object()) {
|
|
continue;
|
|
}
|
|
std::string hash;
|
|
if (item.contains("hash") && item["hash"].is_string()) {
|
|
hash = item["hash"].get<std::string>();
|
|
} else if (item.contains("id") && item["id"].is_string()) {
|
|
hash = item["id"].get<std::string>();
|
|
} else if (item.contains("document_hash") &&
|
|
item["document_hash"].is_string()) {
|
|
hash = item["document_hash"].get<std::string>();
|
|
}
|
|
|
|
if (!hash.empty()) {
|
|
knownHashes.push_back(std::move(hash));
|
|
++itemsInPage;
|
|
if (static_cast<int>(knownHashes.size()) >= kDiscoverHashLimit) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const auto& payload = listRes.value();
|
|
if (payload.contains("items")) {
|
|
collectHashes(payload["items"]);
|
|
}
|
|
// MCP list handler returns "documents" key (not "items")
|
|
if (itemsInPage == 0 && payload.contains("documents")) {
|
|
collectHashes(payload["documents"]);
|
|
}
|
|
if (itemsInPage == 0 && payload.contains("results")) {
|
|
collectHashes(payload["results"]);
|
|
}
|
|
|
|
if (offset == 0 && itemsInPage == 0) {
|
|
int directItemsInPage = 0;
|
|
if (discoverDirectPage(offset, directItemsInPage) && directItemsInPage > 0) {
|
|
useDirectDiscoveryFallback = true;
|
|
itemsInPage = directItemsInPage;
|
|
INFO("Discovery fallback: using direct list() pagination because MCP list "
|
|
"did not return discoverable hashes.");
|
|
}
|
|
}
|
|
} else {
|
|
if (!discoverDirectPage(offset, itemsInPage)) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (itemsInPage < kDiscoverPageSize) {
|
|
done = true;
|
|
}
|
|
}
|
|
|
|
std::sort(knownHashes.begin(), knownHashes.end());
|
|
knownHashes.erase(std::unique(knownHashes.begin(), knownHashes.end()), knownHashes.end());
|
|
}
|
|
|
|
auto initSnap = DaemonSnapshot::capture(discoverClient);
|
|
std::cout << " Corpus docs: " << initSnap.documentsTotal << "\n";
|
|
std::cout << " Known hashes: " << knownHashes.size() << "\n";
|
|
std::cout << " Initial RSS: " << initSnap.memoryUsageMb << " MB\n";
|
|
|
|
if (knownHashes.empty() && initSnap.documentsTotal == 0) {
|
|
SKIP("Large-corpus benchmark requires a non-empty corpus: discovery returned 0 hashes or "
|
|
"daemon reports 0 documents. Verify YAMS_BENCH_DATA_DIR points to populated data.");
|
|
}
|
|
|
|
const std::string benchmarkSessionName =
|
|
"bench_large_corpus_" +
|
|
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
|
if (useSessionGrep) {
|
|
REQUIRE(setupBenchmarkSession(benchmarkSessionName));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Phase 1: Single-client warmup — baseline latency without contention
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
std::cout << "\n--- Phase 1: Single-client warmup (5 ops each) ---\n";
|
|
{
|
|
auto warmCfg = makeClientConfig();
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
// Warm search
|
|
for (int i = 0; i < 5; ++i) {
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(
|
|
0, "search",
|
|
{{"query", "architecture design"}, {"limit", 10}, {"type", cfg.searchType}});
|
|
} else {
|
|
SearchRequest req;
|
|
req.query = "architecture design";
|
|
req.limit = 10;
|
|
req.searchType = cfg.searchType;
|
|
auto direct = yams::cli::run_sync(warmClient.search(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["results"] = nlohmann::json::array();
|
|
for (const auto& r : direct.value().results) {
|
|
j["results"].push_back({{"id", r.id}, {"path", r.path}});
|
|
}
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - t0)
|
|
.count();
|
|
std::cout << " search[" << i << "]: " << us / 1000 << "ms" << (res ? "" : " FAIL")
|
|
<< "\n";
|
|
}
|
|
// Warm list
|
|
for (int i = 0; i < 5; ++i) {
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
int items = 0;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(0, "list", {{"limit", 50}, {"use_session", false}});
|
|
if (res && res.value().contains("items") && res.value()["items"].is_array()) {
|
|
items = static_cast<int>(res.value()["items"].size());
|
|
} else if (res && res.value().contains("documents") &&
|
|
res.value()["documents"].is_array()) {
|
|
items = static_cast<int>(res.value()["documents"].size());
|
|
}
|
|
} else {
|
|
ListRequest req;
|
|
req.limit = 50;
|
|
auto direct = yams::cli::run_sync(warmClient.list(req), kOpTimeout);
|
|
if (direct) {
|
|
items = static_cast<int>(direct.value().items.size());
|
|
nlohmann::json j;
|
|
j["items"] = nlohmann::json::array();
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - t0)
|
|
.count();
|
|
std::cout << " list[" << i << "]: " << us / 1000 << "ms (" << items << " items)"
|
|
<< (res ? "" : " FAIL") << "\n";
|
|
}
|
|
// Warm get
|
|
if (!knownHashes.empty()) {
|
|
for (int i = 0; i < 5 && i < static_cast<int>(knownHashes.size()); ++i) {
|
|
GetRequest req;
|
|
req.hash = knownHashes[i];
|
|
req.metadataOnly = true;
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(
|
|
0, "get", {{"hash", knownHashes[i]}, {"include_content", false}});
|
|
} else {
|
|
auto direct = yams::cli::run_sync(warmClient.get(req), kOpTimeout);
|
|
if (direct) {
|
|
res = nlohmann::json::object();
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
std::chrono::steady_clock::now() - t0)
|
|
.count();
|
|
std::cout << " get[" << i << "]: " << us / 1000 << "ms"
|
|
<< (res ? "" : " FAIL") << "\n";
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Phase 2: Concurrent load with per-op tracing
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
std::cout << "\n--- Phase 2: Concurrent load (" << kTotalClients << " threads x "
|
|
<< kOpsPerClient << " ops) ---\n";
|
|
|
|
// Per-op trace record
|
|
struct OpTrace {
|
|
std::string opType; // "search", "list", "grep", "status", "get", "cat"
|
|
int threadId{0};
|
|
int opIndex{0};
|
|
int64_t latencyUs{0};
|
|
bool success{false};
|
|
std::string errorMsg;
|
|
int resultCount{0}; // items returned (search hits, list items)
|
|
int64_t wallClockMs{0}; // ms since benchmark start
|
|
};
|
|
|
|
// Monitor with sampler
|
|
auto monCfg = makeClientConfig(10s);
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, 1s);
|
|
|
|
// Per-op trace collectors (one per thread for lock-free append)
|
|
std::vector<std::vector<OpTrace>> perThreadTraces(kTotalClients);
|
|
|
|
// Shared counters
|
|
std::atomic<int> searchOps{0}, listOps{0}, grepOps{0}, statusOps{0}, getOps{0}, catOps{0};
|
|
std::atomic<int> searchFails{0}, listFails{0}, grepFails{0}, statusFails{0}, getFails{0},
|
|
catFails{0};
|
|
std::atomic<int> totalCompleted{0};
|
|
std::atomic<int> listInFlight{0};
|
|
const int totalExpected = checkedMulToInt(kTotalClients, kOpsPerClient);
|
|
std::mutex invalidHashMutex;
|
|
std::unordered_set<std::string> invalidHashes;
|
|
|
|
auto markHashInvalid = [&](const std::string& hash) {
|
|
if (hash.empty()) {
|
|
return;
|
|
}
|
|
std::lock_guard<std::mutex> lk(invalidHashMutex);
|
|
invalidHashes.insert(hash);
|
|
};
|
|
|
|
auto sampleKnownHash = [&](std::mt19937& rng) -> std::optional<std::string> {
|
|
if (knownHashes.empty()) {
|
|
return std::nullopt;
|
|
}
|
|
std::uniform_int_distribution<std::size_t> dist(0, knownHashes.size() - 1);
|
|
constexpr int kPickAttempts = 8;
|
|
for (int attempt = 0; attempt < kPickAttempts; ++attempt) {
|
|
std::string candidate = knownHashes[dist(rng)];
|
|
bool isInvalid = false;
|
|
{
|
|
std::lock_guard<std::mutex> lk(invalidHashMutex);
|
|
isInvalid = invalidHashes.find(candidate) != invalidHashes.end();
|
|
}
|
|
if (!isInvalid) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
};
|
|
|
|
// Grep server-side telemetry sampled from grep responses
|
|
std::mutex grepServerStatsMutex;
|
|
std::vector<int64_t> grepExecMsSamples;
|
|
std::vector<int64_t> grepDocsScannedSamples;
|
|
std::vector<int64_t> grepLinesScannedSamples;
|
|
std::vector<int64_t> grepBytesScannedSamples;
|
|
std::vector<int64_t> grepContentRetrievalMsSamples;
|
|
std::vector<int64_t> grepMatchRenderUsSamples;
|
|
std::vector<int64_t> grepWorkerScanMsSamples;
|
|
std::vector<int64_t> grepRegexSearchCallsSamples;
|
|
std::vector<int64_t> grepBmhPrefilterSkipsSamples;
|
|
std::vector<int64_t> grepCandidateDiscoveryMsSamples;
|
|
std::vector<int64_t> grepPathFilteringMsSamples;
|
|
std::vector<int64_t> grepCandidateClassificationMsSamples;
|
|
std::vector<int64_t> grepMetadataBatchFetchMsSamples;
|
|
std::vector<int64_t> grepMetadataEnumerationMsSamples;
|
|
std::vector<int64_t> grepRegexScanMsSamples;
|
|
std::vector<int64_t> grepContentDecodeEstimateMsSamples;
|
|
std::vector<int64_t> grepWorkerCriticalTotalMsSamples;
|
|
std::vector<int64_t> grepWorkerCriticalRetrievalMsSamples;
|
|
std::vector<int64_t> grepWorkerCriticalRegexMsSamples;
|
|
std::vector<int64_t> grepWorkerCriticalOtherMsSamples;
|
|
|
|
// Search/list server-side telemetry sampled from responses
|
|
std::vector<int64_t> searchExecMsSamples;
|
|
std::vector<int64_t> searchDispatchServiceMsSamples;
|
|
std::vector<int64_t> searchDispatchServiceUsSamples;
|
|
std::vector<int64_t> searchDispatchMapSortMsSamples;
|
|
std::vector<int64_t> searchDispatchMapSortUsSamples;
|
|
std::vector<int64_t> searchDispatchFeedbackMsSamples;
|
|
std::vector<int64_t> searchDispatchFeedbackUsSamples;
|
|
std::vector<int64_t> searchDispatchResponseMsSamples;
|
|
std::vector<int64_t> searchDispatchResponseUsSamples;
|
|
std::vector<int64_t> searchDispatchTotalMsSamples;
|
|
std::vector<int64_t> searchDispatchTotalUsSamples;
|
|
std::vector<int64_t> searchTimingEmbeddingUsSamples;
|
|
std::vector<int64_t> searchTimingTextUsSamples;
|
|
std::vector<int64_t> searchTimingPathUsSamples;
|
|
std::vector<int64_t> searchTimingVectorUsSamples;
|
|
std::vector<int64_t> searchTimingEntityVectorUsSamples;
|
|
std::vector<int64_t> searchTimingCompressedAnnUsSamples;
|
|
std::vector<int64_t> searchTimingKgUsSamples;
|
|
std::vector<int64_t> searchTimingMetadataUsSamples;
|
|
std::vector<int64_t> searchTimingTagUsSamples;
|
|
std::vector<int64_t> searchTimingMultiVectorUsSamples;
|
|
std::vector<int64_t> searchTimingConceptsUsSamples;
|
|
std::vector<int64_t> searchTimingRerankerUsSamples;
|
|
std::vector<int64_t> searchTimingGraphRerankUsSamples;
|
|
std::vector<int64_t> searchSubphraseFtsHitSamples;
|
|
std::vector<int64_t> searchAggressiveFtsHitSamples;
|
|
std::vector<int64_t> searchGraphExpansionFtsHitSamples;
|
|
std::vector<int64_t> searchGraphDocExpansionFtsHitSamples;
|
|
std::vector<int64_t> searchMultiVectorRawHitSamples;
|
|
std::vector<int64_t> searchGraphVectorRawHitSamples;
|
|
std::vector<int64_t> searchGraphQueryNeighborSeedDocSamples;
|
|
std::vector<int64_t> listServiceExecMsSamples;
|
|
std::vector<int64_t> listDispatchServiceMsSamples;
|
|
std::vector<int64_t> listDispatchServiceUsSamples;
|
|
std::vector<int64_t> listDispatchMapMsSamples;
|
|
std::vector<int64_t> listDispatchMapUsSamples;
|
|
std::vector<int64_t> listDispatchTotalMsSamples;
|
|
std::vector<int64_t> listDispatchTotalUsSamples;
|
|
std::vector<nlohmann::json> searchDebugSamples;
|
|
|
|
auto collectSearchServerTelemetry = [&](const nlohmann::json& payload) {
|
|
const auto searchStats = [&]() -> nlohmann::json {
|
|
if (payload.contains("search_stats")) {
|
|
return payload["search_stats"];
|
|
}
|
|
if (payload.contains("searchStats")) {
|
|
return payload["searchStats"];
|
|
}
|
|
return nlohmann::json::object();
|
|
}();
|
|
|
|
std::lock_guard<std::mutex> lk(grepServerStatsMutex);
|
|
if (searchDebugSamples.size() < 3 && !searchStats.empty()) {
|
|
searchDebugSamples.push_back(searchStats);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(payload, {"execution_time_ms", "executionTimeMs"})) {
|
|
searchExecMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_service_ms"})) {
|
|
searchDispatchServiceMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_service_us"})) {
|
|
searchDispatchServiceUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_map_sort_ms"})) {
|
|
searchDispatchMapSortMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_map_sort_us"})) {
|
|
searchDispatchMapSortUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_feedback_ms"})) {
|
|
searchDispatchFeedbackMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_feedback_us"})) {
|
|
searchDispatchFeedbackUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_response_ms"})) {
|
|
searchDispatchResponseMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_response_us"})) {
|
|
searchDispatchResponseUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_total_ms"})) {
|
|
searchDispatchTotalMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_dispatch_total_us"})) {
|
|
searchDispatchTotalUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_embedding_us"})) {
|
|
searchTimingEmbeddingUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_text_us"})) {
|
|
searchTimingTextUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_path_us"})) {
|
|
searchTimingPathUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_vector_us"})) {
|
|
searchTimingVectorUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_entity_vector_us"})) {
|
|
searchTimingEntityVectorUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_compressed_ann_us"})) {
|
|
searchTimingCompressedAnnUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_kg_us"})) {
|
|
searchTimingKgUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_metadata_us"})) {
|
|
searchTimingMetadataUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_tag_us"})) {
|
|
searchTimingTagUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_multi_vector_us"})) {
|
|
searchTimingMultiVectorUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_concepts_us"})) {
|
|
searchTimingConceptsUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_reranker_us"})) {
|
|
searchTimingRerankerUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"timing_graph_rerank_us"})) {
|
|
searchTimingGraphRerankUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"subphrase_fts_hit_count"})) {
|
|
searchSubphraseFtsHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"aggressive_fts_hit_count"})) {
|
|
searchAggressiveFtsHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"graph_expansion_fts_hit_count"})) {
|
|
searchGraphExpansionFtsHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"graph_doc_expansion_fts_hit_count"})) {
|
|
searchGraphDocExpansionFtsHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"multi_vector_raw_hit_count"})) {
|
|
searchMultiVectorRawHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"graph_vector_raw_hit_count"})) {
|
|
searchGraphVectorRawHitSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"graph_query_neighbor_seed_docs"})) {
|
|
searchGraphQueryNeighborSeedDocSamples.push_back(*v);
|
|
}
|
|
};
|
|
|
|
auto collectListServerTelemetry = [&](const nlohmann::json& payload) {
|
|
const auto listStats = [&]() -> nlohmann::json {
|
|
if (payload.contains("list_stats")) {
|
|
return payload["list_stats"];
|
|
}
|
|
if (payload.contains("listStats")) {
|
|
return payload["listStats"];
|
|
}
|
|
return nlohmann::json::object();
|
|
}();
|
|
|
|
std::lock_guard<std::mutex> lk(grepServerStatsMutex);
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"service_execution_time_ms"})) {
|
|
listServiceExecMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_service_ms"})) {
|
|
listDispatchServiceMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_service_us"})) {
|
|
listDispatchServiceUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_map_ms"})) {
|
|
listDispatchMapMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_map_us"})) {
|
|
listDispatchMapUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_total_ms"})) {
|
|
listDispatchTotalMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(listStats, {"phase_dispatch_total_us"})) {
|
|
listDispatchTotalUsSamples.push_back(*v);
|
|
}
|
|
};
|
|
|
|
// Live progress reporter
|
|
std::atomic<bool> progressStop{false};
|
|
auto progressStart = std::chrono::steady_clock::now();
|
|
std::thread progressThread([&]() {
|
|
int lastCompleted = 0;
|
|
while (!progressStop.load()) {
|
|
std::this_thread::sleep_for(2s);
|
|
int cur = totalCompleted.load();
|
|
auto elapsed =
|
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - progressStart)
|
|
.count();
|
|
double rate = cur > 0 ? cur / elapsed : 0.0;
|
|
int delta = cur - lastCompleted;
|
|
lastCompleted = cur;
|
|
auto snap = DaemonSnapshot::capture(monitorClient);
|
|
std::cout << " [" << std::fixed << std::setprecision(1) << elapsed << "s] " << cur
|
|
<< "/" << totalExpected << " ops (" << std::setprecision(1) << rate
|
|
<< " ops/s, +" << delta << ") "
|
|
<< "RSS=" << std::setprecision(0) << snap.memoryUsageMb << "MB "
|
|
<< "conns=" << snap.activeConnections << "/" << snap.maxConnections << " "
|
|
<< "pressure=" << snap.pressureLevel << "\n";
|
|
}
|
|
});
|
|
|
|
std::atomic<bool> go{false};
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(kTotalClients);
|
|
|
|
// Search queries
|
|
static const char* corpusQueries[] = {
|
|
"architecture design patterns", "security vulnerability analysis",
|
|
"memory management allocation", "network protocol implementation",
|
|
"database query optimization", "concurrent thread synchronization",
|
|
"error handling recovery", "configuration file parsing",
|
|
"build system compilation", "testing framework assertions",
|
|
};
|
|
|
|
static const char* corpusGrepPatterns[] = {
|
|
"TODO|FIXME", "error|exception", "thread|mutex|lock",
|
|
"config|settings", "benchmark|performance", "daemon|socket",
|
|
"search|index", "vector|embedding", "plugin|model",
|
|
"timeout|retry",
|
|
};
|
|
|
|
int threadIdx = 0;
|
|
|
|
// --- Search threads ---
|
|
for (int s = 0; s < kSearchClients; ++s) {
|
|
int tid = threadIdx++;
|
|
perThreadTraces[tid].reserve(kOpsPerClient);
|
|
threads.emplace_back([&, tid, s]() {
|
|
std::optional<DaemonClient> client;
|
|
if (!useMcpPath) {
|
|
auto ccfg = makeClientConfig();
|
|
client.emplace(ccfg);
|
|
}
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerClient; ++i) {
|
|
SearchRequest req;
|
|
req.query = corpusQueries[(s * kOpsPerClient + i) % 10];
|
|
req.limit = (i % 3 == 0) ? 5 : (i % 3 == 1) ? 10 : 25;
|
|
req.searchType = cfg.searchType;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "search",
|
|
{{"query", req.query}, {"limit", req.limit}, {"type", req.searchType}});
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->search(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["results"] = nlohmann::json::array();
|
|
for (const auto& r : direct.value().results) {
|
|
j["results"].push_back({{"id", r.id}});
|
|
}
|
|
j["execution_time_ms"] = direct.value().elapsed.count();
|
|
j["query_info"] = direct.value().queryInfo;
|
|
j["search_stats"] = direct.value().searchStats;
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart).count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "search";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
trace.success = true;
|
|
const auto& payload = res.value();
|
|
if (payload.contains("results") && payload["results"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["results"].size());
|
|
}
|
|
collectSearchServerTelemetry(payload);
|
|
searchOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
searchFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- List threads ---
|
|
for (int l = 0; l < kListClients; ++l) {
|
|
int tid = threadIdx++;
|
|
perThreadTraces[tid].reserve(kOpsPerClient);
|
|
threads.emplace_back([&, tid]() {
|
|
std::optional<DaemonClient> client;
|
|
if (!useMcpPath) {
|
|
auto ccfg = makeClientConfig();
|
|
client.emplace(ccfg);
|
|
}
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerClient; ++i) {
|
|
ListRequest req;
|
|
req.limit = (i % 4 == 0) ? 10 : (i % 4 == 1) ? 25 : (i % 4 == 2) ? 50 : 100;
|
|
|
|
bool listPermitAcquired = false;
|
|
if (kListInflightCap > 0) {
|
|
while (!listPermitAcquired) {
|
|
int cur = listInFlight.load(std::memory_order_relaxed);
|
|
if (cur < kListInflightCap) {
|
|
listPermitAcquired = listInFlight.compare_exchange_weak(
|
|
cur, cur + 1, std::memory_order_relaxed);
|
|
}
|
|
if (!listPermitAcquired) {
|
|
std::this_thread::yield();
|
|
}
|
|
}
|
|
}
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(static_cast<size_t>(tid), "list",
|
|
{{"limit", req.limit}, {"use_session", false}});
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->list(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["items"] = nlohmann::json::array();
|
|
for (const auto& it : direct.value().items) {
|
|
j["items"].push_back({{"hash", it.hash}});
|
|
}
|
|
j["query_info"] = direct.value().queryInfo;
|
|
j["list_stats"] = direct.value().listStats;
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
|
|
if (kListInflightCap > 0 && listPermitAcquired) {
|
|
listInFlight.fetch_sub(1, std::memory_order_relaxed);
|
|
}
|
|
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart).count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "list";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
const auto& payload = res.value();
|
|
trace.success = true;
|
|
if (payload.contains("items") && payload["items"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["items"].size());
|
|
} else if (payload.contains("documents") && payload["documents"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["documents"].size());
|
|
}
|
|
collectListServerTelemetry(payload);
|
|
listOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
listFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Status/Get threads ---
|
|
for (int g = 0; g < kGrepClients; ++g) {
|
|
int tid = threadIdx++;
|
|
perThreadTraces[tid].reserve(kOpsPerClient);
|
|
threads.emplace_back([&, tid, g]() {
|
|
std::optional<DaemonClient> client;
|
|
if (!useMcpPath) {
|
|
auto ccfg = makeClientConfig();
|
|
client.emplace(ccfg);
|
|
}
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerClient; ++i) {
|
|
const char* pattern = corpusGrepPatterns[(g * kOpsPerClient + i) % 10];
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "grep",
|
|
{{"pattern", pattern},
|
|
{"max_count", (i % 2 == 0) ? 25 : 50},
|
|
{"ignore_case", (i % 3 == 0)},
|
|
{"context", i % 2},
|
|
{"use_session", useSessionGrep},
|
|
{"session", useSessionGrep ? benchmarkSessionName : ""}});
|
|
} else {
|
|
GrepRequest req;
|
|
req.pattern = pattern;
|
|
req.maxMatches = static_cast<size_t>((i % 2 == 0) ? 25 : 50);
|
|
req.caseInsensitive = (i % 3 == 0);
|
|
req.contextLines = i % 2;
|
|
req.useSession = useSessionGrep;
|
|
req.sessionName = useSessionGrep ? benchmarkSessionName : std::string{};
|
|
auto direct = yams::cli::run_sync(client->grep(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["match_count"] = direct.value().totalMatches;
|
|
j["file_count"] = direct.value().filesSearched;
|
|
j["regex_matches"] = direct.value().regexMatches;
|
|
j["semantic_matches"] = direct.value().semanticMatches;
|
|
j["execution_time_ms"] = direct.value().executionTimeMs;
|
|
j["query_info"] = direct.value().queryInfo;
|
|
j["search_stats"] = direct.value().searchStats;
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart).count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "grep";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
const auto& payload = res.value();
|
|
trace.success = true;
|
|
if (payload.contains("match_count") &&
|
|
payload["match_count"].is_number_integer()) {
|
|
trace.resultCount = payload["match_count"].get<int>();
|
|
}
|
|
|
|
const auto searchStats = [&]() -> nlohmann::json {
|
|
if (payload.contains("search_stats")) {
|
|
return payload["search_stats"];
|
|
}
|
|
if (payload.contains("searchStats")) {
|
|
return payload["searchStats"];
|
|
}
|
|
return nlohmann::json::object();
|
|
}();
|
|
|
|
std::lock_guard<std::mutex> lk(grepServerStatsMutex);
|
|
if (auto v =
|
|
jsonGetInt64AnyKey(payload, {"execution_time_ms", "executionTimeMs"})) {
|
|
grepExecMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"docs_scanned"})) {
|
|
grepDocsScannedSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"lines_scanned"})) {
|
|
grepLinesScannedSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"bytes_scanned"})) {
|
|
grepBytesScannedSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"content_retrieval_ms"})) {
|
|
grepContentRetrievalMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"match_render_us"})) {
|
|
grepMatchRenderUsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_worker_scan_ms"})) {
|
|
grepWorkerScanMsSamples.push_back(*v);
|
|
}
|
|
if (auto v =
|
|
jsonGetInt64AnyKey(searchStats, {"phase_candidate_discovery_ms"})) {
|
|
grepCandidateDiscoveryMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_path_filtering_ms"})) {
|
|
grepPathFilteringMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats,
|
|
{"phase_candidate_classification_ms"})) {
|
|
grepCandidateClassificationMsSamples.push_back(*v);
|
|
}
|
|
if (auto v =
|
|
jsonGetInt64AnyKey(searchStats, {"phase_metadata_batch_fetch_ms"})) {
|
|
grepMetadataBatchFetchMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"regex_scan_ms"})) {
|
|
grepRegexScanMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"content_decode_ms_estimate"})) {
|
|
grepContentDecodeEstimateMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"worker_critical_total_ms"})) {
|
|
grepWorkerCriticalTotalMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats,
|
|
{"worker_critical_content_retrieval_ms"})) {
|
|
grepWorkerCriticalRetrievalMsSamples.push_back(*v);
|
|
}
|
|
if (auto v =
|
|
jsonGetInt64AnyKey(searchStats, {"worker_critical_regex_scan_ms"})) {
|
|
grepWorkerCriticalRegexMsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"worker_critical_other_ms"})) {
|
|
grepWorkerCriticalOtherMsSamples.push_back(*v);
|
|
}
|
|
{
|
|
int64_t metadataEnumerationMs = 0;
|
|
bool hasMetadataEnumeration = false;
|
|
if (auto v =
|
|
jsonGetInt64AnyKey(searchStats, {"phase_candidate_discovery_ms"})) {
|
|
metadataEnumerationMs += *v;
|
|
hasMetadataEnumeration = true;
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"phase_path_filtering_ms"})) {
|
|
metadataEnumerationMs += *v;
|
|
hasMetadataEnumeration = true;
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats,
|
|
{"phase_candidate_classification_ms"})) {
|
|
metadataEnumerationMs += *v;
|
|
hasMetadataEnumeration = true;
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats,
|
|
{"phase_metadata_batch_fetch_ms"})) {
|
|
metadataEnumerationMs += *v;
|
|
hasMetadataEnumeration = true;
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"metadata_enumeration_ms"})) {
|
|
metadataEnumerationMs = *v;
|
|
hasMetadataEnumeration = true;
|
|
}
|
|
if (hasMetadataEnumeration) {
|
|
grepMetadataEnumerationMsSamples.push_back(metadataEnumerationMs);
|
|
}
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"regex_search_calls"})) {
|
|
grepRegexSearchCallsSamples.push_back(*v);
|
|
}
|
|
if (auto v = jsonGetInt64AnyKey(searchStats, {"bmh_prefilter_skips"})) {
|
|
grepBmhPrefilterSkipsSamples.push_back(*v);
|
|
}
|
|
grepOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
grepFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Status/Get/Cat threads ---
|
|
for (int g = 0; g < kStatusGetClients; ++g) {
|
|
int tid = threadIdx++;
|
|
perThreadTraces[tid].reserve(kOpsPerClient);
|
|
threads.emplace_back([&, tid, g]() {
|
|
std::optional<DaemonClient> client;
|
|
if (!useMcpPath) {
|
|
auto ccfg = makeClientConfig();
|
|
client.emplace(ccfg);
|
|
}
|
|
std::mt19937 rng(static_cast<unsigned>(g * 997 + 17));
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < kOpsPerClient; ++i) {
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
if (extensionLikeProfile) {
|
|
const int slot = (i + g) % 20;
|
|
const bool doStatus = (slot == 0) || knownHashes.empty();
|
|
const bool doSearch = !doStatus && (slot >= 1 && slot <= 4);
|
|
const bool doList = !doStatus && !doSearch && (slot >= 5 && slot <= 10);
|
|
const bool doCat = !doStatus && !doSearch && !doList && (slot == 19);
|
|
|
|
if (doStatus) {
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(static_cast<size_t>(tid), "status",
|
|
nlohmann::json::object());
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->status(), kOpTimeout);
|
|
if (direct) {
|
|
res = nlohmann::json::object();
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "status";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
trace.success = true;
|
|
statusOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
statusFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
continue;
|
|
}
|
|
|
|
if (doSearch) {
|
|
SearchRequest req;
|
|
req.query = corpusQueries[(g * kOpsPerClient + i) % 10];
|
|
req.limit = 5;
|
|
req.searchType = cfg.searchType;
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(static_cast<size_t>(tid), "search",
|
|
{{"query", req.query},
|
|
{"limit", req.limit},
|
|
{"type", req.searchType}});
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->search(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["results"] = nlohmann::json::array();
|
|
for (const auto& r : direct.value().results) {
|
|
j["results"].push_back({{"id", r.id}});
|
|
}
|
|
j["execution_time_ms"] = direct.value().elapsed.count();
|
|
j["query_info"] = direct.value().queryInfo;
|
|
j["search_stats"] = direct.value().searchStats;
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "search";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
const auto& payload = res.value();
|
|
trace.success = true;
|
|
if (payload.contains("results") && payload["results"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["results"].size());
|
|
}
|
|
collectSearchServerTelemetry(payload);
|
|
searchOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
searchFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
continue;
|
|
}
|
|
|
|
if (doList) {
|
|
ListRequest req;
|
|
req.limit = ((i % 2) == 0) ? 10 : 25;
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res =
|
|
queryMcpStepForSlot(static_cast<size_t>(tid), "list",
|
|
{{"limit", req.limit}, {"use_session", false}});
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->list(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["items"] = nlohmann::json::array();
|
|
for (const auto& it : direct.value().items) {
|
|
j["items"].push_back({{"hash", it.hash}});
|
|
}
|
|
j["query_info"] = direct.value().queryInfo;
|
|
j["list_stats"] = direct.value().listStats;
|
|
res = j;
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "list";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
const auto& payload = res.value();
|
|
trace.success = true;
|
|
if (payload.contains("items") && payload["items"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["items"].size());
|
|
} else if (payload.contains("documents") &&
|
|
payload["documents"].is_array()) {
|
|
trace.resultCount = static_cast<int>(payload["documents"].size());
|
|
}
|
|
collectListServerTelemetry(payload);
|
|
listOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
listFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
continue;
|
|
}
|
|
|
|
auto runGetOrCat = [&](const std::string& hash,
|
|
bool requestContent) -> yams::Result<nlohmann::json> {
|
|
if (requestContent) {
|
|
if (useMcpPath) {
|
|
return queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "get",
|
|
{{"hash", hash}, {"include_content", true}});
|
|
}
|
|
CatRequest req;
|
|
req.hash = hash;
|
|
auto direct = yams::cli::run_sync(client->cat(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["has_content"] = direct.value().hasContent;
|
|
j["size"] = direct.value().size;
|
|
return j;
|
|
}
|
|
return yams::Error{direct.error()};
|
|
}
|
|
|
|
if (useMcpPath) {
|
|
return queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "get",
|
|
{{"hash", hash}, {"include_content", false}});
|
|
}
|
|
GetRequest req;
|
|
req.hash = hash;
|
|
req.metadataOnly = true;
|
|
auto direct = yams::cli::run_sync(client->get(req), kOpTimeout);
|
|
if (direct) {
|
|
return nlohmann::json::object();
|
|
}
|
|
return yams::Error{direct.error()};
|
|
};
|
|
|
|
const bool requestContent = doCat;
|
|
yams::Result<nlohmann::json> res =
|
|
yams::Error{yams::ErrorCode::NotFound, "No hash candidates available"};
|
|
constexpr int kHashRetryAttempts = 2;
|
|
for (int attempt = 0; attempt < kHashRetryAttempts; ++attempt) {
|
|
auto hashOpt = sampleKnownHash(rng);
|
|
if (!hashOpt.has_value()) {
|
|
break;
|
|
}
|
|
const auto& hash = *hashOpt;
|
|
res = runGetOrCat(hash, requestContent);
|
|
if (res) {
|
|
break;
|
|
}
|
|
if (isDocumentMissingFailure(res.error().message)) {
|
|
markHashInvalid(hash);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = doCat ? "cat" : "get";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
trace.success = true;
|
|
if (doCat) {
|
|
if (res.value().contains("size") && res.value()["size"].is_number()) {
|
|
trace.resultCount =
|
|
static_cast<int>(res.value()["size"].get<uint64_t>());
|
|
}
|
|
catOps.fetch_add(1);
|
|
} else {
|
|
getOps.fetch_add(1);
|
|
}
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
if (doCat) {
|
|
catFails.fetch_add(1);
|
|
} else {
|
|
getFails.fetch_add(1);
|
|
}
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
totalCompleted.fetch_add(1);
|
|
continue;
|
|
}
|
|
|
|
bool isStatus = (i % kStatusEveryN == 0) || knownHashes.empty();
|
|
|
|
if (isStatus) {
|
|
yams::Result<nlohmann::json> res;
|
|
if (useMcpPath) {
|
|
res = queryMcpStepForSlot(static_cast<size_t>(tid), "status",
|
|
nlohmann::json::object());
|
|
} else {
|
|
auto direct = yams::cli::run_sync(client->status(), kOpTimeout);
|
|
if (direct) {
|
|
res = nlohmann::json::object();
|
|
} else {
|
|
res = yams::Error{direct.error()};
|
|
}
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = "status";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
trace.success = true;
|
|
statusOps.fetch_add(1);
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
statusFails.fetch_add(1);
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
} else {
|
|
bool doCat = (i % kCatEveryN == 0);
|
|
auto runGetOrCat = [&](const std::string& hash,
|
|
bool requestContent) -> yams::Result<nlohmann::json> {
|
|
if (requestContent) {
|
|
if (useMcpPath) {
|
|
return queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "get",
|
|
{{"hash", hash}, {"include_content", true}});
|
|
}
|
|
CatRequest req;
|
|
req.hash = hash;
|
|
auto direct = yams::cli::run_sync(client->cat(req), kOpTimeout);
|
|
if (direct) {
|
|
nlohmann::json j;
|
|
j["has_content"] = direct.value().hasContent;
|
|
j["size"] = direct.value().size;
|
|
return j;
|
|
}
|
|
return yams::Error{direct.error()};
|
|
}
|
|
|
|
if (useMcpPath) {
|
|
return queryMcpStepForSlot(
|
|
static_cast<size_t>(tid), "get",
|
|
{{"hash", hash}, {"include_content", false}});
|
|
}
|
|
GetRequest req;
|
|
req.hash = hash;
|
|
req.metadataOnly = true;
|
|
auto direct = yams::cli::run_sync(client->get(req), kOpTimeout);
|
|
if (direct) {
|
|
return nlohmann::json::object();
|
|
}
|
|
return yams::Error{direct.error()};
|
|
};
|
|
|
|
const bool requestContent = doCat;
|
|
yams::Result<nlohmann::json> res =
|
|
yams::Error{yams::ErrorCode::NotFound, "No hash candidates available"};
|
|
constexpr int kHashRetryAttempts = 2;
|
|
for (int attempt = 0; attempt < kHashRetryAttempts; ++attempt) {
|
|
auto hashOpt = sampleKnownHash(rng);
|
|
if (!hashOpt.has_value()) {
|
|
break;
|
|
}
|
|
const auto& hash = *hashOpt;
|
|
res = runGetOrCat(hash, requestContent);
|
|
if (res) {
|
|
break;
|
|
}
|
|
if (isDocumentMissingFailure(res.error().message)) {
|
|
markHashInvalid(hash);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
auto wallMs =
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - globalStart)
|
|
.count();
|
|
|
|
OpTrace trace;
|
|
trace.opType = doCat ? "cat" : "get";
|
|
trace.threadId = tid;
|
|
trace.opIndex = i;
|
|
trace.latencyUs = latUs;
|
|
trace.wallClockMs = wallMs;
|
|
if (res) {
|
|
trace.success = true;
|
|
if (doCat) {
|
|
if (res.value().contains("size") && res.value()["size"].is_number()) {
|
|
trace.resultCount =
|
|
static_cast<int>(res.value()["size"].get<uint64_t>());
|
|
}
|
|
catOps.fetch_add(1);
|
|
} else {
|
|
getOps.fetch_add(1);
|
|
}
|
|
} else {
|
|
trace.success = false;
|
|
trace.errorMsg = res.error().message;
|
|
if (doCat) {
|
|
catFails.fetch_add(1);
|
|
} else {
|
|
getFails.fetch_add(1);
|
|
}
|
|
}
|
|
perThreadTraces[tid].push_back(std::move(trace));
|
|
}
|
|
totalCompleted.fetch_add(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
go.store(true);
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
progressStop.store(true);
|
|
progressThread.join();
|
|
sampler.stop();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Analysis
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
|
|
// Aggregate latency vectors from per-thread traces
|
|
std::vector<int64_t> allSearchLat, allListLat, allGrepLat, allStatusLat, allGetLat, allCatLat;
|
|
std::vector<OpTrace> allTraces;
|
|
std::map<std::string, int> errorCategories;
|
|
|
|
for (auto& threadTraces : perThreadTraces) {
|
|
for (auto& tr : threadTraces) {
|
|
allTraces.push_back(tr);
|
|
if (tr.success) {
|
|
if (tr.opType == "search")
|
|
pushMetricSample(allSearchLat, tr.latencyUs);
|
|
else if (tr.opType == "list")
|
|
pushMetricSample(allListLat, tr.latencyUs);
|
|
else if (tr.opType == "grep")
|
|
pushMetricSample(allGrepLat, tr.latencyUs);
|
|
else if (tr.opType == "status")
|
|
pushMetricSample(allStatusLat, tr.latencyUs);
|
|
else if (tr.opType == "get")
|
|
pushMetricSample(allGetLat, tr.latencyUs);
|
|
else if (tr.opType == "cat")
|
|
pushMetricSample(allCatLat, tr.latencyUs);
|
|
} else {
|
|
errorCategories[classifyFailureMessage(tr.errorMsg)]++;
|
|
}
|
|
}
|
|
}
|
|
|
|
auto searchStats = PercentileStats::compute(allSearchLat);
|
|
auto listStats = PercentileStats::compute(allListLat);
|
|
auto grepStats = PercentileStats::compute(allGrepLat);
|
|
auto statusStats = PercentileStats::compute(allStatusLat);
|
|
auto getStats = PercentileStats::compute(allGetLat);
|
|
auto catStats = PercentileStats::compute(allCatLat);
|
|
|
|
auto summarizeSamples = [](const std::vector<int64_t>& values) {
|
|
json summary{{"count", values.size()},
|
|
{"min", 0},
|
|
{"p50", 0},
|
|
{"p90", 0},
|
|
{"p95", 0},
|
|
{"p99", 0},
|
|
{"max", 0},
|
|
{"mean", 0.0}};
|
|
if (values.empty()) {
|
|
return summary;
|
|
}
|
|
auto sortedValues = values;
|
|
std::sort(sortedValues.begin(), sortedValues.end());
|
|
const auto atPct = [&](double pct) {
|
|
const auto idx =
|
|
std::min(sortedValues.size() - 1,
|
|
static_cast<size_t>(pct * static_cast<double>(sortedValues.size())));
|
|
return sortedValues[idx];
|
|
};
|
|
sortedValues.erase(std::remove_if(sortedValues.begin(), sortedValues.end(),
|
|
[](int64_t value) { return value < 0; }),
|
|
sortedValues.end());
|
|
for (auto& value : sortedValues) {
|
|
value = clampMetricSample(value);
|
|
}
|
|
if (sortedValues.empty()) {
|
|
return summary;
|
|
}
|
|
std::sort(sortedValues.begin(), sortedValues.end());
|
|
const long double total = std::accumulate(
|
|
sortedValues.begin(), sortedValues.end(), 0.0L,
|
|
[](long double sum, int64_t value) { return sum + static_cast<long double>(value); });
|
|
const auto mean =
|
|
static_cast<double>(total / static_cast<long double>(sortedValues.size()));
|
|
summary["min"] = sortedValues.front();
|
|
summary["p50"] = atPct(0.50);
|
|
summary["p90"] = atPct(0.90);
|
|
summary["p95"] = atPct(0.95);
|
|
summary["p99"] = atPct(0.99);
|
|
summary["max"] = sortedValues.back();
|
|
summary["mean"] = mean;
|
|
return summary;
|
|
};
|
|
|
|
const json grepExecStats = summarizeSamples(grepExecMsSamples);
|
|
const json grepDocsScannedStats = summarizeSamples(grepDocsScannedSamples);
|
|
const json grepLinesScannedStats = summarizeSamples(grepLinesScannedSamples);
|
|
const json grepBytesScannedStats = summarizeSamples(grepBytesScannedSamples);
|
|
const json grepContentRetrievalStats = summarizeSamples(grepContentRetrievalMsSamples);
|
|
const json grepMatchRenderStats = summarizeSamples(grepMatchRenderUsSamples);
|
|
const json grepWorkerScanStats = summarizeSamples(grepWorkerScanMsSamples);
|
|
const json grepCandidateDiscoveryStats = summarizeSamples(grepCandidateDiscoveryMsSamples);
|
|
const json grepPathFilteringStats = summarizeSamples(grepPathFilteringMsSamples);
|
|
const json grepCandidateClassificationStats =
|
|
summarizeSamples(grepCandidateClassificationMsSamples);
|
|
const json grepMetadataBatchFetchStats = summarizeSamples(grepMetadataBatchFetchMsSamples);
|
|
const json grepMetadataEnumerationStats = summarizeSamples(grepMetadataEnumerationMsSamples);
|
|
const json grepRegexScanStats = summarizeSamples(grepRegexScanMsSamples);
|
|
const json grepContentDecodeEstimateStats =
|
|
summarizeSamples(grepContentDecodeEstimateMsSamples);
|
|
const json grepWorkerCriticalTotalStats = summarizeSamples(grepWorkerCriticalTotalMsSamples);
|
|
const json grepWorkerCriticalRetrievalStats =
|
|
summarizeSamples(grepWorkerCriticalRetrievalMsSamples);
|
|
const json grepWorkerCriticalRegexStats = summarizeSamples(grepWorkerCriticalRegexMsSamples);
|
|
const json grepWorkerCriticalOtherStats = summarizeSamples(grepWorkerCriticalOtherMsSamples);
|
|
const json grepRegexSearchCallsStats = summarizeSamples(grepRegexSearchCallsSamples);
|
|
const json grepBmhPrefilterSkipsStats = summarizeSamples(grepBmhPrefilterSkipsSamples);
|
|
const json searchExecStats = summarizeSamples(searchExecMsSamples);
|
|
const json searchDispatchServiceStats = summarizeSamples(searchDispatchServiceMsSamples);
|
|
const json searchDispatchServiceUsStats = summarizeSamples(searchDispatchServiceUsSamples);
|
|
const json searchDispatchMapSortStats = summarizeSamples(searchDispatchMapSortMsSamples);
|
|
const json searchDispatchMapSortUsStats = summarizeSamples(searchDispatchMapSortUsSamples);
|
|
const json searchDispatchFeedbackStats = summarizeSamples(searchDispatchFeedbackMsSamples);
|
|
const json searchDispatchFeedbackUsStats = summarizeSamples(searchDispatchFeedbackUsSamples);
|
|
const json searchDispatchResponseStats = summarizeSamples(searchDispatchResponseMsSamples);
|
|
const json searchDispatchResponseUsStats = summarizeSamples(searchDispatchResponseUsSamples);
|
|
const json searchDispatchTotalStats = summarizeSamples(searchDispatchTotalMsSamples);
|
|
const json searchDispatchTotalUsStats = summarizeSamples(searchDispatchTotalUsSamples);
|
|
const json searchTimingEmbeddingUsStats = summarizeSamples(searchTimingEmbeddingUsSamples);
|
|
const json searchTimingTextUsStats = summarizeSamples(searchTimingTextUsSamples);
|
|
const json searchTimingPathUsStats = summarizeSamples(searchTimingPathUsSamples);
|
|
const json searchTimingVectorUsStats = summarizeSamples(searchTimingVectorUsSamples);
|
|
const json searchTimingEntityVectorUsStats =
|
|
summarizeSamples(searchTimingEntityVectorUsSamples);
|
|
const json searchTimingCompressedAnnUsStats =
|
|
summarizeSamples(searchTimingCompressedAnnUsSamples);
|
|
const json searchTimingKgUsStats = summarizeSamples(searchTimingKgUsSamples);
|
|
const json searchTimingMetadataUsStats = summarizeSamples(searchTimingMetadataUsSamples);
|
|
const json searchTimingTagUsStats = summarizeSamples(searchTimingTagUsSamples);
|
|
const json searchTimingMultiVectorUsStats = summarizeSamples(searchTimingMultiVectorUsSamples);
|
|
const json searchTimingConceptsUsStats = summarizeSamples(searchTimingConceptsUsSamples);
|
|
const json searchTimingRerankerUsStats = summarizeSamples(searchTimingRerankerUsSamples);
|
|
const json searchTimingGraphRerankUsStats = summarizeSamples(searchTimingGraphRerankUsSamples);
|
|
const json searchSubphraseFtsHitStats = summarizeSamples(searchSubphraseFtsHitSamples);
|
|
const json searchAggressiveFtsHitStats = summarizeSamples(searchAggressiveFtsHitSamples);
|
|
const json searchGraphExpansionFtsHitStats =
|
|
summarizeSamples(searchGraphExpansionFtsHitSamples);
|
|
const json searchGraphDocExpansionFtsHitStats =
|
|
summarizeSamples(searchGraphDocExpansionFtsHitSamples);
|
|
const json searchMultiVectorRawHitStats = summarizeSamples(searchMultiVectorRawHitSamples);
|
|
const json searchGraphVectorRawHitStats = summarizeSamples(searchGraphVectorRawHitSamples);
|
|
const json searchGraphQueryNeighborSeedDocStats =
|
|
summarizeSamples(searchGraphQueryNeighborSeedDocSamples);
|
|
const json listServiceExecStats = summarizeSamples(listServiceExecMsSamples);
|
|
const json listDispatchServiceStats = summarizeSamples(listDispatchServiceMsSamples);
|
|
const json listDispatchServiceUsStats = summarizeSamples(listDispatchServiceUsSamples);
|
|
const json listDispatchMapStats = summarizeSamples(listDispatchMapMsSamples);
|
|
const json listDispatchMapUsStats = summarizeSamples(listDispatchMapUsSamples);
|
|
const json listDispatchTotalStats = summarizeSamples(listDispatchTotalMsSamples);
|
|
const json listDispatchTotalUsStats = summarizeSamples(listDispatchTotalUsSamples);
|
|
|
|
int totalOps = searchOps.load() + listOps.load() + grepOps.load() + statusOps.load() +
|
|
getOps.load() + catOps.load();
|
|
int totalFails = searchFails.load() + listFails.load() + grepFails.load() + statusFails.load() +
|
|
getFails.load() + catFails.load();
|
|
double opsPerSec = safeRate(static_cast<double>(totalOps), globalElapsed);
|
|
size_t invalidHashCount = 0;
|
|
{
|
|
std::lock_guard<std::mutex> lk(invalidHashMutex);
|
|
invalidHashCount = invalidHashes.size();
|
|
}
|
|
|
|
// Memory watermarks from time series
|
|
auto samples = sampler.getSamples();
|
|
double peakRss = 0.0, minRss = std::numeric_limits<double>::max();
|
|
for (auto& s : samples) {
|
|
peakRss = std::max(peakRss, s.snap.memoryUsageMb);
|
|
minRss = std::min(minRss, s.snap.memoryUsageMb);
|
|
}
|
|
if (samples.empty())
|
|
minRss = 0.0;
|
|
|
|
std::cout << "\n=== Large Corpus Results (instrumented) ===\n";
|
|
std::cout << " Corpus: " << finalSnap.documentsTotal << " docs\n";
|
|
std::cout << " Layout: " << kSearchClients << " search + " << kListClients
|
|
<< " list + " << kGrepClients << " grep + " << kStatusGetClients
|
|
<< " status/get/cat\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed
|
|
<< "s\n";
|
|
std::cout << " Total ops: " << totalOps << " (" << std::setprecision(1) << opsPerSec
|
|
<< " ops/s)\n";
|
|
std::cout << " Total failures: " << totalFails << "\n\n";
|
|
if (invalidHashCount > 0) {
|
|
std::cout << " Invalid hash candidates quarantined: " << invalidHashCount << "\n\n";
|
|
}
|
|
|
|
std::cout << " Op breakdown:\n";
|
|
std::cout << " Search: " << searchOps.load() << " ok, " << searchFails.load() << " fail\n";
|
|
std::cout << " List: " << listOps.load() << " ok, " << listFails.load() << " fail\n";
|
|
std::cout << " Grep: " << grepOps.load() << " ok, " << grepFails.load() << " fail\n";
|
|
std::cout << " Status: " << statusOps.load() << " ok, " << statusFails.load() << " fail\n";
|
|
std::cout << " Get: " << getOps.load() << " ok, " << getFails.load() << " fail\n\n";
|
|
std::cout << " Cat: " << catOps.load() << " ok, " << catFails.load() << " fail\n\n";
|
|
|
|
printPercentiles("Search latency", searchStats);
|
|
printPercentiles("List latency ", listStats);
|
|
printPercentiles("Grep latency ", grepStats);
|
|
printPercentiles("Status latency", statusStats);
|
|
printPercentiles("Get latency ", getStats);
|
|
printPercentiles("Cat latency ", catStats);
|
|
|
|
if (grepExecStats.value("count", 0) > 0 || grepWorkerScanStats.value("count", 0) > 0) {
|
|
std::cout << "\n Grep server telemetry:\n";
|
|
std::cout << " execution_ms: mean=" << grepExecStats.value("mean", 0.0)
|
|
<< " p95=" << grepExecStats.value("p95", 0)
|
|
<< " max=" << grepExecStats.value("max", 0) << "\n";
|
|
std::cout << " metadata_enumeration_ms: mean="
|
|
<< grepMetadataEnumerationStats.value("mean", 0.0)
|
|
<< " p95=" << grepMetadataEnumerationStats.value("p95", 0)
|
|
<< " max=" << grepMetadataEnumerationStats.value("max", 0) << "\n";
|
|
std::cout << " candidate_discovery_ms: mean="
|
|
<< grepCandidateDiscoveryStats.value("mean", 0.0)
|
|
<< " p95=" << grepCandidateDiscoveryStats.value("p95", 0) << "\n";
|
|
std::cout << " path_filtering_ms: mean=" << grepPathFilteringStats.value("mean", 0.0)
|
|
<< " p95=" << grepPathFilteringStats.value("p95", 0) << "\n";
|
|
std::cout << " candidate_classification_ms: mean="
|
|
<< grepCandidateClassificationStats.value("mean", 0.0)
|
|
<< " p95=" << grepCandidateClassificationStats.value("p95", 0) << "\n";
|
|
std::cout << " metadata_batch_fetch_ms: mean="
|
|
<< grepMetadataBatchFetchStats.value("mean", 0.0)
|
|
<< " p95=" << grepMetadataBatchFetchStats.value("p95", 0) << "\n";
|
|
std::cout << " worker_scan_ms: mean=" << grepWorkerScanStats.value("mean", 0.0)
|
|
<< " p95=" << grepWorkerScanStats.value("p95", 0)
|
|
<< " max=" << grepWorkerScanStats.value("max", 0) << "\n";
|
|
std::cout << " content_retrieval_ms: mean="
|
|
<< grepContentRetrievalStats.value("mean", 0.0)
|
|
<< " p95=" << grepContentRetrievalStats.value("p95", 0)
|
|
<< " max=" << grepContentRetrievalStats.value("max", 0) << "\n";
|
|
std::cout << " match_render_us: mean=" << grepMatchRenderStats.value("mean", 0.0)
|
|
<< " p95=" << grepMatchRenderStats.value("p95", 0)
|
|
<< " max=" << grepMatchRenderStats.value("max", 0) << "\n";
|
|
std::cout << " content_decode_ms_estimate: mean="
|
|
<< grepContentDecodeEstimateStats.value("mean", 0.0)
|
|
<< " p95=" << grepContentDecodeEstimateStats.value("p95", 0)
|
|
<< " max=" << grepContentDecodeEstimateStats.value("max", 0) << "\n";
|
|
std::cout << " regex_scan_ms: mean=" << grepRegexScanStats.value("mean", 0.0)
|
|
<< " p95=" << grepRegexScanStats.value("p95", 0)
|
|
<< " max=" << grepRegexScanStats.value("max", 0) << "\n";
|
|
std::cout << " worker_critical_total_ms: mean="
|
|
<< grepWorkerCriticalTotalStats.value("mean", 0.0)
|
|
<< " p95=" << grepWorkerCriticalTotalStats.value("p95", 0)
|
|
<< " max=" << grepWorkerCriticalTotalStats.value("max", 0) << "\n";
|
|
std::cout << " worker_critical_content_retrieval_ms: mean="
|
|
<< grepWorkerCriticalRetrievalStats.value("mean", 0.0)
|
|
<< " p95=" << grepWorkerCriticalRetrievalStats.value("p95", 0)
|
|
<< " max=" << grepWorkerCriticalRetrievalStats.value("max", 0) << "\n";
|
|
std::cout << " worker_critical_regex_scan_ms: mean="
|
|
<< grepWorkerCriticalRegexStats.value("mean", 0.0)
|
|
<< " p95=" << grepWorkerCriticalRegexStats.value("p95", 0)
|
|
<< " max=" << grepWorkerCriticalRegexStats.value("max", 0) << "\n";
|
|
std::cout << " worker_critical_other_ms: mean="
|
|
<< grepWorkerCriticalOtherStats.value("mean", 0.0)
|
|
<< " p95=" << grepWorkerCriticalOtherStats.value("p95", 0)
|
|
<< " max=" << grepWorkerCriticalOtherStats.value("max", 0) << "\n";
|
|
}
|
|
|
|
if (searchDispatchTotalStats.value("count", 0) > 0 ||
|
|
listDispatchTotalStats.value("count", 0) > 0) {
|
|
std::cout << "\n Search/list server telemetry:\n";
|
|
std::cout << " search_total_ms: mean=" << searchDispatchTotalStats.value("mean", 0.0)
|
|
<< " p95=" << searchDispatchTotalStats.value("p95", 0)
|
|
<< " max=" << searchDispatchTotalStats.value("max", 0) << "\n";
|
|
std::cout << " list_total_ms: mean=" << listDispatchTotalStats.value("mean", 0.0)
|
|
<< " p95=" << listDispatchTotalStats.value("p95", 0)
|
|
<< " max=" << listDispatchTotalStats.value("max", 0) << "\n";
|
|
auto printSearchComponentUs = [&](const char* label, const json& stats) {
|
|
if (stats.value("count", 0) <= 0) {
|
|
return;
|
|
}
|
|
std::cout << " " << label << ": mean=" << (stats.value("mean", 0.0) / 1000.0)
|
|
<< "ms p95=" << (static_cast<double>(stats.value("p95", 0)) / 1000.0)
|
|
<< "ms\n";
|
|
};
|
|
printSearchComponentUs("timing_embedding", searchTimingEmbeddingUsStats);
|
|
printSearchComponentUs("timing_text", searchTimingTextUsStats);
|
|
printSearchComponentUs("timing_path", searchTimingPathUsStats);
|
|
printSearchComponentUs("timing_vector", searchTimingVectorUsStats);
|
|
printSearchComponentUs("timing_entity_vector", searchTimingEntityVectorUsStats);
|
|
printSearchComponentUs("timing_compressed_ann", searchTimingCompressedAnnUsStats);
|
|
printSearchComponentUs("timing_kg", searchTimingKgUsStats);
|
|
printSearchComponentUs("timing_metadata", searchTimingMetadataUsStats);
|
|
printSearchComponentUs("timing_tag", searchTimingTagUsStats);
|
|
printSearchComponentUs("timing_multi_vector", searchTimingMultiVectorUsStats);
|
|
printSearchComponentUs("timing_concepts", searchTimingConceptsUsStats);
|
|
printSearchComponentUs("timing_reranker", searchTimingRerankerUsStats);
|
|
printSearchComponentUs("timing_graph_rerank", searchTimingGraphRerankUsStats);
|
|
if (searchGraphDocExpansionFtsHitStats.value("count", 0) > 0) {
|
|
std::cout << " graph_doc_expansion_fts_hit_count: mean="
|
|
<< searchGraphDocExpansionFtsHitStats.value("mean", 0.0)
|
|
<< " p95=" << searchGraphDocExpansionFtsHitStats.value("p95", 0) << "\n";
|
|
}
|
|
if (searchMultiVectorRawHitStats.value("count", 0) > 0) {
|
|
std::cout << " multi_vector_raw_hit_count: mean="
|
|
<< searchMultiVectorRawHitStats.value("mean", 0.0)
|
|
<< " p95=" << searchMultiVectorRawHitStats.value("p95", 0) << "\n";
|
|
}
|
|
if (searchGraphVectorRawHitStats.value("count", 0) > 0) {
|
|
std::cout << " graph_vector_raw_hit_count: mean="
|
|
<< searchGraphVectorRawHitStats.value("mean", 0.0)
|
|
<< " p95=" << searchGraphVectorRawHitStats.value("p95", 0) << "\n";
|
|
}
|
|
}
|
|
|
|
std::cout << "\n Memory:\n";
|
|
std::cout << " Initial: " << std::setprecision(0) << initSnap.memoryUsageMb << " MB\n";
|
|
std::cout << " Peak: " << peakRss << " MB\n";
|
|
std::cout << " Min: " << minRss << " MB\n";
|
|
std::cout << " Final: " << finalSnap.memoryUsageMb << " MB\n";
|
|
std::cout << " Delta: " << (finalSnap.memoryUsageMb - initSnap.memoryUsageMb) << " MB\n\n";
|
|
|
|
if (!errorCategories.empty()) {
|
|
std::cout << " Error categories:\n";
|
|
for (auto& [cat, count] : errorCategories) {
|
|
std::cout << " " << cat << ": " << count << "\n";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
|
|
// Hotspot analysis: find slowest ops
|
|
std::sort(allTraces.begin(), allTraces.end(),
|
|
[](const OpTrace& a, const OpTrace& b) { return a.latencyUs > b.latencyUs; });
|
|
|
|
std::cout << " Top 10 slowest operations:\n";
|
|
for (int i = 0; i < 10 && i < static_cast<int>(allTraces.size()); ++i) {
|
|
auto& tr = allTraces[i];
|
|
std::cout << " " << (i + 1) << ". " << tr.opType << " t" << tr.threadId << "#"
|
|
<< tr.opIndex << ": " << tr.latencyUs / 1000 << "ms"
|
|
<< (tr.success ? "" : " FAIL:" + tr.errorMsg) << " @" << tr.wallClockMs << "ms\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
// Thread-level hotspots
|
|
std::map<int, std::vector<int64_t>> perThreadSuccessLatUs;
|
|
std::map<int, int> perThreadFails;
|
|
for (const auto& tr : allTraces) {
|
|
if (tr.success) {
|
|
pushMetricSample(perThreadSuccessLatUs[tr.threadId], tr.latencyUs);
|
|
} else {
|
|
perThreadFails[tr.threadId]++;
|
|
}
|
|
}
|
|
struct ThreadHotspot {
|
|
int threadId{0};
|
|
int64_t p95Us{0};
|
|
double meanUs{0.0};
|
|
int failures{0};
|
|
size_t okCount{0};
|
|
};
|
|
std::vector<ThreadHotspot> threadHotspots;
|
|
threadHotspots.reserve(static_cast<size_t>(kTotalClients));
|
|
for (int tid = 0; tid < kTotalClients; ++tid) {
|
|
auto it = perThreadSuccessLatUs.find(tid);
|
|
std::vector<int64_t> lats =
|
|
(it != perThreadSuccessLatUs.end()) ? it->second : std::vector<int64_t>{};
|
|
auto stats = PercentileStats::compute(lats);
|
|
threadHotspots.push_back(
|
|
ThreadHotspot{tid, stats.p95, stats.mean, perThreadFails[tid], stats.count});
|
|
}
|
|
std::sort(threadHotspots.begin(), threadHotspots.end(),
|
|
[](const ThreadHotspot& a, const ThreadHotspot& b) {
|
|
if (a.p95Us != b.p95Us) {
|
|
return a.p95Us > b.p95Us;
|
|
}
|
|
return a.failures > b.failures;
|
|
});
|
|
|
|
std::cout << " Slowest threads (p95):\n";
|
|
for (int i = 0; i < 5 && i < static_cast<int>(threadHotspots.size()); ++i) {
|
|
const auto& th = threadHotspots[i];
|
|
std::cout << " t" << th.threadId << ": p95=" << th.p95Us / 1000
|
|
<< "ms mean=" << std::fixed << std::setprecision(1) << (th.meanUs / 1000.0)
|
|
<< "ms ok=" << th.okCount << " fail=" << th.failures << "\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
// Time-window hotspots
|
|
struct WindowStats {
|
|
int ops{0};
|
|
int fails{0};
|
|
int64_t totalLatUs{0};
|
|
};
|
|
std::map<int, WindowStats> windows;
|
|
for (const auto& tr : allTraces) {
|
|
const auto boundedWallMs = clampMetricSample(tr.wallClockMs, kMaxBenchmarkWallMs);
|
|
const int bucket = clampInt64ToInt(boundedWallMs / std::max(1, kHotspotWindowMs), 0,
|
|
std::numeric_limits<int>::max());
|
|
auto& ws = windows[bucket];
|
|
ws.ops++;
|
|
ws.totalLatUs = checkedMetricAdd(ws.totalLatUs, tr.latencyUs);
|
|
if (!tr.success) {
|
|
ws.fails++;
|
|
}
|
|
}
|
|
struct WindowHotspot {
|
|
int bucket{0};
|
|
double avgMs{0.0};
|
|
int ops{0};
|
|
int fails{0};
|
|
};
|
|
std::vector<WindowHotspot> windowHotspots;
|
|
windowHotspots.reserve(windows.size());
|
|
for (const auto& [bucket, ws] : windows) {
|
|
if (!isHotspotWindowEligible(ws.ops, kTotalClients)) {
|
|
continue;
|
|
}
|
|
const double avgMs =
|
|
(ws.ops > 0) ? (static_cast<double>(ws.totalLatUs) / ws.ops / 1000.0) : 0.0;
|
|
windowHotspots.push_back(WindowHotspot{bucket, avgMs, ws.ops, ws.fails});
|
|
}
|
|
std::sort(windowHotspots.begin(), windowHotspots.end(),
|
|
[](const WindowHotspot& a, const WindowHotspot& b) {
|
|
if (a.avgMs != b.avgMs) {
|
|
return a.avgMs > b.avgMs;
|
|
}
|
|
return a.fails > b.fails;
|
|
});
|
|
|
|
std::cout << " Slowest time windows:\n";
|
|
for (int i = 0; i < 5 && i < static_cast<int>(windowHotspots.size()); ++i) {
|
|
const auto& hw = windowHotspots[i];
|
|
const int64_t startMs = checkedMetricMul(hw.bucket, kHotspotWindowMs, kMaxBenchmarkWallMs);
|
|
const int64_t endMs = checkedMetricAdd(startMs, kHotspotWindowMs, kMaxBenchmarkWallMs);
|
|
std::cout << " [" << startMs << ", " << endMs << ")ms"
|
|
<< " avg=" << std::fixed << std::setprecision(1) << hw.avgMs
|
|
<< "ms ops=" << hw.ops << " fail=" << hw.fails << "\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
// Latency distribution (histogram buckets)
|
|
auto printHistogram = [](const std::string& label, std::vector<int64_t>& lats) {
|
|
if (lats.empty())
|
|
return;
|
|
std::sort(lats.begin(), lats.end());
|
|
// Buckets: <1ms, 1-10ms, 10-100ms, 100ms-1s, 1-10s, >10s
|
|
int b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0;
|
|
for (auto us : lats) {
|
|
if (us < 1000)
|
|
b0++;
|
|
else if (us < 10000)
|
|
b1++;
|
|
else if (us < 100000)
|
|
b2++;
|
|
else if (us < 1000000)
|
|
b3++;
|
|
else if (us < 10000000)
|
|
b4++;
|
|
else
|
|
b5++;
|
|
}
|
|
std::cout << " " << label << " histogram: <1ms=" << b0 << " 1-10ms=" << b1
|
|
<< " 10-100ms=" << b2 << " 100ms-1s=" << b3 << " 1-10s=" << b4 << " >10s=" << b5
|
|
<< "\n";
|
|
};
|
|
|
|
std::cout << " Latency distribution:\n";
|
|
printHistogram("Search", allSearchLat);
|
|
printHistogram("List ", allListLat);
|
|
printHistogram("Grep ", allGrepLat);
|
|
printHistogram("Status", allStatusLat);
|
|
printHistogram("Get ", allGetLat);
|
|
printHistogram("Cat ", allCatLat);
|
|
std::cout << "\n";
|
|
|
|
CHECK(totalOps > 0);
|
|
// Soft-fail on errors — we want the data even if some ops time out
|
|
if (totalFails > 0) {
|
|
WARN("Had " << totalFails << " failures out of " << totalExpected << " total ops");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// JSONL output with full traces
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
json timeSeries = json::array();
|
|
for (auto& s : samples) {
|
|
timeSeries.push_back(json{{"elapsed_ms", s.elapsedMs}, {"snapshot", s.snap.toJson()}});
|
|
}
|
|
|
|
// Per-op latency trace array (sorted by wall clock)
|
|
std::sort(allTraces.begin(), allTraces.end(),
|
|
[](const OpTrace& a, const OpTrace& b) { return a.wallClockMs < b.wallClockMs; });
|
|
json traceArray = json::array();
|
|
for (auto& tr : allTraces) {
|
|
json j{{"op", tr.opType}, {"tid", tr.threadId}, {"idx", tr.opIndex},
|
|
{"lat_us", tr.latencyUs}, {"ok", tr.success}, {"items", tr.resultCount},
|
|
{"wall_ms", tr.wallClockMs}};
|
|
if (!tr.errorMsg.empty())
|
|
j["error"] = tr.errorMsg;
|
|
traceArray.push_back(std::move(j));
|
|
}
|
|
|
|
json threadHotspotsJson = json::array();
|
|
for (const auto& th : threadHotspots) {
|
|
threadHotspotsJson.push_back(json{{"tid", th.threadId},
|
|
{"p95_us", th.p95Us},
|
|
{"mean_us", th.meanUs},
|
|
{"ok", th.okCount},
|
|
{"fail", th.failures}});
|
|
}
|
|
|
|
json windowHotspotsJson = json::array();
|
|
for (const auto& hw : windowHotspots) {
|
|
const int64_t startMs = static_cast<int64_t>(hw.bucket) * kHotspotWindowMs;
|
|
const int64_t endMs = startMs + kHotspotWindowMs;
|
|
windowHotspotsJson.push_back(json{{"start_ms", startMs},
|
|
{"end_ms", endMs},
|
|
{"avg_ms", hw.avgMs},
|
|
{"ops", hw.ops},
|
|
{"fail", hw.fails}});
|
|
}
|
|
|
|
const std::string recordTestName =
|
|
externalAgentChurnProfile ? "large_corpus_reads_churn" : "large_corpus_reads";
|
|
const double mcpTransientRatio = (mcpQueryCalls.load() > 0)
|
|
? static_cast<double>(mcpTransientSessionCalls.load()) /
|
|
static_cast<double>(mcpQueryCalls.load())
|
|
: 0.0;
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", recordTestName},
|
|
{"request_path", useMcpPath ? "mcp_query" : "daemon_ipc"},
|
|
{"backend", cfg.backend},
|
|
{"auth_mode", cfg.authMode},
|
|
{"data_dir", cfg.dataDir->string()},
|
|
{"num_clients", kTotalClients},
|
|
{"corpus_docs", finalSnap.documentsTotal},
|
|
{"layout", json{{"search_clients", kSearchClients},
|
|
{"list_clients", kListClients},
|
|
{"grep_clients", kGrepClients},
|
|
{"status_get_clients", kStatusGetClients},
|
|
{"ops_per_client", kOpsPerClient},
|
|
{"op_timeout_s", kOpTimeoutS},
|
|
{"status_every_n", kStatusEveryN},
|
|
{"cat_every_n", kCatEveryN},
|
|
{"list_inflight_cap", kListInflightCap},
|
|
{"mcp_session_churn_every_n", kMcpSessionChurnEveryN},
|
|
{"usage_profile", cfg.usageProfile}}},
|
|
{"total_ops", totalOps},
|
|
{"total_failures", totalFails},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"ops_per_sec", opsPerSec},
|
|
{"mcp", json{{"query_calls", mcpQueryCalls.load()},
|
|
{"transient_session_calls", mcpTransientSessionCalls.load()},
|
|
{"transient_session_ratio", mcpTransientRatio}}},
|
|
{"hash_pool",
|
|
json{{"known_hashes", knownHashes.size()}, {"invalid_quarantined", invalidHashCount}}},
|
|
{"search", json{{"ops", searchOps.load()},
|
|
{"fails", searchFails.load()},
|
|
{"latency", searchStats.toJson()}}},
|
|
{"list", json{{"ops", listOps.load()},
|
|
{"fails", listFails.load()},
|
|
{"latency", listStats.toJson()}}},
|
|
{"grep", json{{"ops", grepOps.load()},
|
|
{"fails", grepFails.load()},
|
|
{"latency", grepStats.toJson()}}},
|
|
{"status", json{{"ops", statusOps.load()},
|
|
{"fails", statusFails.load()},
|
|
{"latency", statusStats.toJson()}}},
|
|
{"get",
|
|
json{{"ops", getOps.load()}, {"fails", getFails.load()}, {"latency", getStats.toJson()}}},
|
|
{"cat",
|
|
json{{"ops", catOps.load()}, {"fails", catFails.load()}, {"latency", catStats.toJson()}}},
|
|
{"grep_server",
|
|
json{{"execution_time_ms", grepExecStats},
|
|
{"metadata_enumeration_ms", grepMetadataEnumerationStats},
|
|
{"phase_candidate_discovery_ms", grepCandidateDiscoveryStats},
|
|
{"phase_path_filtering_ms", grepPathFilteringStats},
|
|
{"phase_candidate_classification_ms", grepCandidateClassificationStats},
|
|
{"phase_metadata_batch_fetch_ms", grepMetadataBatchFetchStats},
|
|
{"docs_scanned", grepDocsScannedStats},
|
|
{"lines_scanned", grepLinesScannedStats},
|
|
{"bytes_scanned", grepBytesScannedStats},
|
|
{"content_retrieval_ms", grepContentRetrievalStats},
|
|
{"match_render_us", grepMatchRenderStats},
|
|
{"content_decode_ms_estimate", grepContentDecodeEstimateStats},
|
|
{"phase_worker_scan_ms", grepWorkerScanStats},
|
|
{"regex_scan_ms", grepRegexScanStats},
|
|
{"worker_critical_total_ms", grepWorkerCriticalTotalStats},
|
|
{"worker_critical_content_retrieval_ms", grepWorkerCriticalRetrievalStats},
|
|
{"worker_critical_regex_scan_ms", grepWorkerCriticalRegexStats},
|
|
{"worker_critical_other_ms", grepWorkerCriticalOtherStats},
|
|
{"regex_search_calls", grepRegexSearchCallsStats},
|
|
{"bmh_prefilter_skips", grepBmhPrefilterSkipsStats}}},
|
|
{"search_server",
|
|
json{{"execution_time_ms", searchExecStats},
|
|
{"phase_dispatch_service_ms", searchDispatchServiceStats},
|
|
{"phase_dispatch_service_us", searchDispatchServiceUsStats},
|
|
{"phase_dispatch_map_sort_ms", searchDispatchMapSortStats},
|
|
{"phase_dispatch_map_sort_us", searchDispatchMapSortUsStats},
|
|
{"phase_dispatch_feedback_ms", searchDispatchFeedbackStats},
|
|
{"phase_dispatch_feedback_us", searchDispatchFeedbackUsStats},
|
|
{"phase_dispatch_response_ms", searchDispatchResponseStats},
|
|
{"phase_dispatch_response_us", searchDispatchResponseUsStats},
|
|
{"phase_dispatch_total_ms", searchDispatchTotalStats},
|
|
{"phase_dispatch_total_us", searchDispatchTotalUsStats},
|
|
{"timing_embedding_us", searchTimingEmbeddingUsStats},
|
|
{"timing_text_us", searchTimingTextUsStats},
|
|
{"timing_path_us", searchTimingPathUsStats},
|
|
{"timing_vector_us", searchTimingVectorUsStats},
|
|
{"timing_entity_vector_us", searchTimingEntityVectorUsStats},
|
|
{"timing_compressed_ann_us", searchTimingCompressedAnnUsStats},
|
|
{"timing_kg_us", searchTimingKgUsStats},
|
|
{"timing_metadata_us", searchTimingMetadataUsStats},
|
|
{"timing_tag_us", searchTimingTagUsStats},
|
|
{"timing_multi_vector_us", searchTimingMultiVectorUsStats},
|
|
{"timing_concepts_us", searchTimingConceptsUsStats},
|
|
{"timing_reranker_us", searchTimingRerankerUsStats},
|
|
{"timing_graph_rerank_us", searchTimingGraphRerankUsStats},
|
|
{"subphrase_fts_hit_count", searchSubphraseFtsHitStats},
|
|
{"aggressive_fts_hit_count", searchAggressiveFtsHitStats},
|
|
{"graph_expansion_fts_hit_count", searchGraphExpansionFtsHitStats},
|
|
{"graph_doc_expansion_fts_hit_count", searchGraphDocExpansionFtsHitStats},
|
|
{"multi_vector_raw_hit_count", searchMultiVectorRawHitStats},
|
|
{"graph_vector_raw_hit_count", searchGraphVectorRawHitStats},
|
|
{"graph_query_neighbor_seed_docs", searchGraphQueryNeighborSeedDocStats}}},
|
|
{"search_debug_samples", searchDebugSamples},
|
|
{"list_server", json{{"service_execution_time_ms", listServiceExecStats},
|
|
{"phase_dispatch_service_ms", listDispatchServiceStats},
|
|
{"phase_dispatch_service_us", listDispatchServiceUsStats},
|
|
{"phase_dispatch_map_ms", listDispatchMapStats},
|
|
{"phase_dispatch_map_us", listDispatchMapUsStats},
|
|
{"phase_dispatch_total_ms", listDispatchTotalStats},
|
|
{"phase_dispatch_total_us", listDispatchTotalUsStats}}},
|
|
{"memory", json{{"initial_mb", initSnap.memoryUsageMb},
|
|
{"peak_mb", peakRss},
|
|
{"min_mb", minRss},
|
|
{"final_mb", finalSnap.memoryUsageMb},
|
|
{"delta_mb", finalSnap.memoryUsageMb - initSnap.memoryUsageMb}}},
|
|
{"error_categories", errorCategories},
|
|
{"hotspots", json{{"window_ms", kHotspotWindowMs},
|
|
{"threads", threadHotspotsJson},
|
|
{"windows", windowHotspotsJson}}},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"time_series", timeSeries},
|
|
{"op_traces", traceArray},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
|
|
std::cout << " Results written to: " << cfg.outputPath.string() << "\n\n";
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMBEDDINGS-ENABLED BENCHMARK
|
|
// =============================================================================
|
|
|
|
TEST_CASE("Multi-client ingestion: embeddings pipeline", "[!benchmark][multi-client][embeddings]") {
|
|
// Full ingestion benchmark with real plugins loaded: ONNX embeddings,
|
|
// symbol extraction, entity recognition. Measures the complete pipeline
|
|
// including embedding generation which is the most expensive stage.
|
|
//
|
|
// Requires either:
|
|
// YAMS_BENCH_ENABLE_PLUGINS=1 with plugins and models available, or
|
|
// YAMS_BENCH_DATA_DIR pointing to a corpus (can combine both for
|
|
// ingest-into-existing-corpus testing).
|
|
auto cfg = BenchConfig::fromEnv();
|
|
if (!cfg.enablePlugins) {
|
|
SKIP("Set YAMS_BENCH_ENABLE_PLUGINS=1 to run the embeddings benchmark");
|
|
}
|
|
|
|
std::cout << "\n=== Embeddings Pipeline Benchmark ===\n";
|
|
std::cout << " Plugins: enabled\n";
|
|
std::cout << " Real model provider: " << (cfg.useRealModelProvider ? "yes" : "mock") << "\n";
|
|
if (cfg.pluginDir)
|
|
std::cout << " Plugin dir: " << cfg.pluginDir->string() << "\n";
|
|
if (cfg.dataDir)
|
|
std::cout << " Data dir: " << cfg.dataDir->string() << "\n";
|
|
|
|
constexpr int kWriterClients = 4;
|
|
constexpr int kSearchClients = 4;
|
|
constexpr int kTotalClients = kWriterClients + kSearchClients;
|
|
|
|
auto opts = benchHarnessOptions(cfg);
|
|
DaemonHarness harness(opts);
|
|
// Plugin loading + model warmup needs extra time
|
|
REQUIRE(harness.start(std::chrono::seconds(120)));
|
|
|
|
// Allow async init to complete (plugin loading, model warmup, connection pools)
|
|
std::this_thread::sleep_for(5s);
|
|
|
|
// Monitor
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 30s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
auto initSnap = DaemonSnapshot::capture(monitorClient);
|
|
std::cout << " Initial docs: " << initSnap.documentsTotal << "\n";
|
|
std::cout << " Docs per writer: " << cfg.docsPerClient << "\n";
|
|
|
|
TimeSeriesSampler sampler;
|
|
sampler.start(monitorClient, 1s);
|
|
|
|
// Latency collectors
|
|
std::mutex addLatMutex, searchLatMutex;
|
|
std::vector<int64_t> addLatencies, searchLatencies;
|
|
std::atomic<int> addOps{0}, searchOps{0};
|
|
std::atomic<int> addFails{0}, searchFails{0};
|
|
|
|
// Collect hashes for search/get during ingest
|
|
std::mutex hashMutex;
|
|
std::vector<std::string> ingestedHashes;
|
|
|
|
std::atomic<bool> go{false};
|
|
std::atomic<bool> writersFinished{false};
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(kTotalClients);
|
|
|
|
// Writer threads: ingest with embeddings pipeline active
|
|
for (int w = 0; w < kWriterClients; ++w) {
|
|
threads.emplace_back([&, w]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 60s; // embedding can be slow
|
|
DaemonClient client(ccfg);
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "embed_w" + std::to_string(w) + "_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(w, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "embeddings", "writer-" + std::to_string(w)};
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 60s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res && !res.value().hash.empty()) {
|
|
addOps.fetch_add(1);
|
|
{
|
|
std::lock_guard<std::mutex> lk(addLatMutex);
|
|
addLatencies.push_back(latUs);
|
|
}
|
|
{
|
|
std::lock_guard<std::mutex> lk(hashMutex);
|
|
ingestedHashes.push_back(res.value().hash);
|
|
}
|
|
} else {
|
|
addFails.fetch_add(1);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Search threads: query while ingest + embeddings are running
|
|
for (int s = 0; s < kSearchClients; ++s) {
|
|
threads.emplace_back([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
while (!go.load())
|
|
std::this_thread::yield();
|
|
|
|
static const char* queries[] = {
|
|
"architecture design", "performance tuning parameters",
|
|
"ingestion pipeline stages", "resource governance pressure",
|
|
"IPC transport protocol",
|
|
};
|
|
|
|
// Keep searching until writers finish
|
|
int i = 0;
|
|
while (!writersFinished.load()) {
|
|
SearchRequest req;
|
|
req.query = queries[i % 5];
|
|
req.limit = 10;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.search(req), 30s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
searchOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(searchLatMutex);
|
|
searchLatencies.push_back(latUs);
|
|
} else {
|
|
searchFails.fetch_add(1);
|
|
}
|
|
++i;
|
|
}
|
|
});
|
|
}
|
|
|
|
go.store(true);
|
|
|
|
// Wait for writers, then signal searchers to stop
|
|
for (int w = 0; w < kWriterClients; ++w)
|
|
threads[w].join();
|
|
writersFinished.store(true);
|
|
|
|
// Join searchers
|
|
for (int s = kWriterClients; s < kTotalClients; ++s)
|
|
threads[s].join();
|
|
|
|
sampler.stop();
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
// Wait for embeddings pipeline to drain
|
|
bool drained =
|
|
waitForDrain(monitorClient, std::chrono::seconds(std::max(cfg.drainTimeoutSecs, 300)));
|
|
auto finalSnap = DaemonSnapshot::capture(monitorClient);
|
|
|
|
auto addStats = PercentileStats::compute(addLatencies);
|
|
auto searchStats = PercentileStats::compute(searchLatencies);
|
|
|
|
int totalOps = addOps.load() + searchOps.load();
|
|
int totalFails = addFails.load() + searchFails.load();
|
|
double addThroughput = safeRate(static_cast<double>(addOps.load()), globalElapsed);
|
|
double searchThroughput = safeRate(static_cast<double>(searchOps.load()), globalElapsed);
|
|
|
|
std::cout << "\n=== Embeddings Pipeline Results ===\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
std::cout << " Total ops: " << totalOps << " Failures: " << totalFails << "\n\n";
|
|
|
|
std::cout << " Ingest:\n";
|
|
std::cout << " Added: " << addOps.load() << " (" << std::setprecision(1) << addThroughput
|
|
<< " docs/s) Fails: " << addFails.load() << "\n";
|
|
printPercentiles(" Add latency ", addStats);
|
|
|
|
std::cout << "\n Search (concurrent with ingest):\n";
|
|
std::cout << " Queries: " << searchOps.load() << " (" << std::setprecision(1)
|
|
<< searchThroughput << " q/s) Fails: " << searchFails.load() << "\n";
|
|
printPercentiles(" Search latency", searchStats);
|
|
|
|
std::cout << "\n Pipeline:\n";
|
|
std::cout << " Queue drained: " << (drained ? "yes" : "NO") << "\n";
|
|
std::cout << " Final docs_total: " << finalSnap.documentsTotal << "\n";
|
|
std::cout << " Final memory: " << finalSnap.memoryUsageMb << " MB\n\n";
|
|
|
|
CHECK(addOps.load() > 0);
|
|
CHECK(drained);
|
|
|
|
// Emit JSONL
|
|
json timeSeries = json::array();
|
|
for (auto& s : sampler.getSamples()) {
|
|
timeSeries.push_back(json{{"elapsed_ms", s.elapsedMs}, {"snapshot", s.snap.toJson()}});
|
|
}
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "embeddings_pipeline"},
|
|
{"plugins_enabled", cfg.enablePlugins},
|
|
{"real_model_provider", cfg.useRealModelProvider},
|
|
{"data_dir", cfg.dataDir ? cfg.dataDir->string() : ""},
|
|
{"plugin_dir", cfg.pluginDir ? cfg.pluginDir->string() : ""},
|
|
{"writers", kWriterClients},
|
|
{"searchers", kSearchClients},
|
|
{"docs_per_writer", cfg.docsPerClient},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"add", json{{"ops", addOps.load()},
|
|
{"fails", addFails.load()},
|
|
{"throughput_per_sec", addThroughput},
|
|
{"latency", addStats.toJson()}}},
|
|
{"search", json{{"ops", searchOps.load()},
|
|
{"fails", searchFails.load()},
|
|
{"throughput_per_sec", searchThroughput},
|
|
{"latency", searchStats.toJson()}}},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"drained", drained},
|
|
{"daemon_snapshot_final", finalSnap.toJson()},
|
|
{"time_series", timeSeries},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: connection contention",
|
|
"[!benchmark][multi-client][contention]") {
|
|
// Stress test: many clients making rapid short-lived connections
|
|
auto cfg = BenchConfig::fromEnv();
|
|
int burstClients =
|
|
checkedMulToInt(cfg.numClients, 4, kMaxBenchClients * 4); // Higher contention
|
|
int opsPerClient = 20; // Shorter burst per client
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(harness.start(kStartTimeout));
|
|
|
|
// Monitor
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
|
|
(void)DaemonSnapshot::capture(monitorClient); // warmup connection
|
|
|
|
std::atomic<int> successCount{0};
|
|
std::atomic<int> failCount{0};
|
|
std::atomic<int> retryAfterCount{0};
|
|
std::mutex latMutex;
|
|
std::vector<int64_t> allLatencies;
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
std::vector<std::thread> threads;
|
|
for (int c = 0; c < burstClients; ++c) {
|
|
threads.emplace_back([&, c]() {
|
|
std::vector<int64_t> localLats;
|
|
for (int i = 0; i < opsPerClient; ++i) {
|
|
// Alternate between status, list, and add to test different op types
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
bool success = false;
|
|
|
|
if (i % 3 == 0) {
|
|
auto res = yams::cli::run_sync(client.status(), 10s);
|
|
success = static_cast<bool>(res);
|
|
if (res && res.value().retryAfterMs > 0)
|
|
retryAfterCount.fetch_add(1);
|
|
} else if (i % 3 == 1) {
|
|
ListRequest req;
|
|
req.limit = 5;
|
|
auto res = yams::cli::run_sync(client.list(req), 10s);
|
|
success = static_cast<bool>(res);
|
|
} else {
|
|
AddDocumentRequest req;
|
|
req.name = "burst_c" + std::to_string(c) + "_" + std::to_string(i) + ".md";
|
|
req.content = "Burst test content from client " + std::to_string(c);
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 10s);
|
|
success = static_cast<bool>(res);
|
|
}
|
|
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
localLats.push_back(latUs);
|
|
|
|
if (success)
|
|
successCount.fetch_add(1);
|
|
else
|
|
failCount.fetch_add(1);
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(latMutex);
|
|
allLatencies.insert(allLatencies.end(), localLats.begin(), localLats.end());
|
|
});
|
|
}
|
|
|
|
for (auto& t : threads)
|
|
t.join();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
int totalOps = burstClients * opsPerClient;
|
|
double throughput = static_cast<double>(successCount.load()) / globalElapsed;
|
|
|
|
auto postSnap = DaemonSnapshot::capture(monitorClient);
|
|
auto latStats = PercentileStats::compute(allLatencies);
|
|
|
|
std::cout << "\n=== Connection Contention (N=" << burstClients << " burst clients) ===\n";
|
|
std::cout << " Total ops: " << totalOps << " (success=" << successCount.load()
|
|
<< " fail=" << failCount.load() << ")\n";
|
|
std::cout << " Backpressure signals (retryAfter>0): " << retryAfterCount.load() << "\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
std::cout << " Throughput: " << throughput << " ops/s\n";
|
|
printPercentiles("Op latency", latStats);
|
|
std::cout << " Peak connections: " << postSnap.maxConnections << "\n";
|
|
std::cout << " Forced closes: " << postSnap.requestsProcessed << " total reqs processed\n\n";
|
|
|
|
// Failures acceptable under extreme contention, but should be minority
|
|
double failRate = static_cast<double>(failCount.load()) / static_cast<double>(totalOps);
|
|
CHECK(failRate < 0.20); // Less than 20% failure acceptable under burst
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "connection_contention"},
|
|
{"burst_clients", burstClients},
|
|
{"ops_per_client", opsPerClient},
|
|
{"total_ops", totalOps},
|
|
{"success_count", successCount.load()},
|
|
{"fail_count", failCount.load()},
|
|
{"retry_after_count", retryAfterCount.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"throughput_ops_per_sec", throughput},
|
|
{"latency", latStats.toJson()},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: reader starvation under ingest load",
|
|
"[!benchmark][multi-client][starvation]") {
|
|
// Verify that readers are not starved when heavy ingestion is in progress
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(harness.start(kStartTimeout));
|
|
|
|
// Pre-seed so readers have something to query
|
|
{
|
|
ClientConfig warmCfg;
|
|
warmCfg.socketPath = harness.socketPath();
|
|
warmCfg.autoStart = false;
|
|
warmCfg.requestTimeout = 30s;
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
for (int i = 0; i < 30; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "preseed_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(0, i, cfg.docSizeBytes);
|
|
yams::cli::run_sync(warmClient.streamingAddDocument(req), 30s);
|
|
}
|
|
(void)waitForDrain(warmClient, 30s);
|
|
}
|
|
|
|
// Writer clients (heavy ingestion)
|
|
int numWriters = std::max(1, cfg.numClients - 1);
|
|
// Reader clients (continuous reads)
|
|
int numReaders = std::max(1, cfg.numClients / 2);
|
|
|
|
std::atomic<bool> done{false};
|
|
std::atomic<int> writerDocs{0};
|
|
std::atomic<int> readerOps{0};
|
|
std::atomic<int> readerFails{0};
|
|
std::mutex readerLatMutex;
|
|
std::vector<int64_t> readerLatencies;
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
// Launch writers
|
|
std::vector<std::thread> writerThreads;
|
|
for (int w = 0; w < numWriters; ++w) {
|
|
writerThreads.emplace_back([&, w]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "writer" + std::to_string(w) + "_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(w, i, cfg.docSizeBytes);
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
if (res)
|
|
writerDocs.fetch_add(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Launch readers (run until writers finish)
|
|
std::vector<std::thread> readerThreads;
|
|
for (int r = 0; r < numReaders; ++r) {
|
|
readerThreads.emplace_back([&]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
|
|
while (!done.load()) {
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
ListRequest req;
|
|
req.limit = 10;
|
|
auto res = yams::cli::run_sync(client.list(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
readerOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lock(readerLatMutex);
|
|
readerLatencies.push_back(latUs);
|
|
} else {
|
|
readerFails.fetch_add(1);
|
|
}
|
|
|
|
// Small delay to avoid pure spin
|
|
std::this_thread::sleep_for(10ms);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wait for writers to finish
|
|
for (auto& t : writerThreads)
|
|
t.join();
|
|
done.store(true);
|
|
|
|
// Wait for readers to stop
|
|
for (auto& t : readerThreads)
|
|
t.join();
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
auto readerStats = PercentileStats::compute(readerLatencies);
|
|
|
|
std::cout << "\n=== Reader Starvation Test ===\n";
|
|
std::cout << " Writers: " << numWriters << " (ingested " << writerDocs.load() << " docs)\n";
|
|
std::cout << " Readers: " << numReaders << " (" << readerOps.load() << " ops, "
|
|
<< readerFails.load() << " fails)\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
printPercentiles("Reader latency", readerStats);
|
|
|
|
// Reader p99 should stay reasonable even under write load
|
|
// This threshold can be tuned based on baseline measurements
|
|
if (readerStats.count > 0) {
|
|
std::cout << " Reader throughput: "
|
|
<< safeRate(static_cast<double>(readerOps.load()), globalElapsed) << " ops/s\n\n";
|
|
}
|
|
|
|
CHECK(readerOps.load() > 0); // Readers must make progress
|
|
CHECK(readerFails.load() < readerOps.load()); // Most reads should succeed
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "reader_starvation"},
|
|
{"num_writers", numWriters},
|
|
{"num_readers", numReaders},
|
|
{"docs_per_writer", cfg.docsPerClient},
|
|
{"total_writer_docs", writerDocs.load()},
|
|
{"total_reader_ops", readerOps.load()},
|
|
{"total_reader_fails", readerFails.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"reader_latency", readerStats.toJson()},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|
|
|
|
TEST_CASE("Multi-client ingestion: file access under ingest load",
|
|
"[!benchmark][multi-client][access]") {
|
|
// Validate get/cat responsiveness while concurrent ingestion is running.
|
|
auto cfg = BenchConfig::fromEnv();
|
|
|
|
DaemonHarness harness(benchHarnessOptions());
|
|
REQUIRE(harness.start(kStartTimeout));
|
|
|
|
// Seed a baseline corpus so readers always have accessible hashes.
|
|
std::vector<std::string> knownHashes;
|
|
std::mutex knownHashesMutex;
|
|
{
|
|
ClientConfig warmCfg;
|
|
warmCfg.socketPath = harness.socketPath();
|
|
warmCfg.autoStart = false;
|
|
warmCfg.requestTimeout = 30s;
|
|
DaemonClient warmClient(warmCfg);
|
|
|
|
for (int i = 0; i < 64; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "access_preseed_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(700, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "access-preseed"};
|
|
auto res = yams::cli::run_sync(warmClient.streamingAddDocument(req), 30s);
|
|
if (res && !res.value().hash.empty()) {
|
|
std::lock_guard<std::mutex> lk(knownHashesMutex);
|
|
knownHashes.push_back(res.value().hash);
|
|
}
|
|
}
|
|
(void)waitForDrain(warmClient, 30s);
|
|
}
|
|
|
|
const int numWriters = std::max(1, cfg.numClients - std::max(1, cfg.numClients / 3));
|
|
const int numReaders = std::max(1, cfg.numClients / 3);
|
|
|
|
std::atomic<bool> done{false};
|
|
std::atomic<int> writerDocs{0};
|
|
std::atomic<int> readerOps{0};
|
|
std::atomic<int> readerFails{0};
|
|
std::atomic<int> readerAttempts{0};
|
|
std::atomic<int> getOps{0};
|
|
std::atomic<int> catOps{0};
|
|
std::mutex getLatMutex;
|
|
std::mutex catLatMutex;
|
|
std::vector<int64_t> getLatencies;
|
|
std::vector<int64_t> catLatencies;
|
|
|
|
auto pickHash = [&](std::mt19937& rng) -> std::string {
|
|
std::lock_guard<std::mutex> lk(knownHashesMutex);
|
|
if (knownHashes.empty()) {
|
|
return std::string{};
|
|
}
|
|
std::uniform_int_distribution<std::size_t> dist(0, knownHashes.size() - 1);
|
|
return knownHashes[dist(rng)];
|
|
};
|
|
|
|
auto globalStart = std::chrono::steady_clock::now();
|
|
|
|
std::vector<std::thread> writerThreads;
|
|
writerThreads.reserve(numWriters);
|
|
for (int w = 0; w < numWriters; ++w) {
|
|
writerThreads.emplace_back([&, w]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 30s;
|
|
DaemonClient client(ccfg);
|
|
|
|
for (int i = 0; i < cfg.docsPerClient; ++i) {
|
|
AddDocumentRequest req;
|
|
req.name = "access_writer" + std::to_string(w) + "_" + std::to_string(i) + ".md";
|
|
req.content = generateDocument(w, i, cfg.docSizeBytes);
|
|
req.tags = {"bench", "access", "writer-" + std::to_string(w)};
|
|
|
|
auto res = yams::cli::run_sync(client.streamingAddDocument(req), 30s);
|
|
if (res) {
|
|
writerDocs.fetch_add(1);
|
|
if (!res.value().hash.empty()) {
|
|
std::lock_guard<std::mutex> lk(knownHashesMutex);
|
|
knownHashes.push_back(res.value().hash);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
std::vector<std::thread> readerThreads;
|
|
readerThreads.reserve(numReaders);
|
|
for (int r = 0; r < numReaders; ++r) {
|
|
readerThreads.emplace_back([&, r]() {
|
|
ClientConfig ccfg;
|
|
ccfg.socketPath = harness.socketPath();
|
|
ccfg.autoStart = false;
|
|
ccfg.requestTimeout = 10s;
|
|
DaemonClient client(ccfg);
|
|
|
|
std::mt19937 rng(static_cast<unsigned>(r * 1009 + 17));
|
|
bool doGet = true;
|
|
|
|
while (!done.load()) {
|
|
std::string hash = pickHash(rng);
|
|
if (hash.empty()) {
|
|
std::this_thread::sleep_for(5ms);
|
|
continue;
|
|
}
|
|
readerAttempts.fetch_add(1);
|
|
|
|
// Alternate get (metadata-focused) and cat (content-focused)
|
|
if (doGet) {
|
|
GetRequest req;
|
|
req.hash = hash;
|
|
req.metadataOnly = true;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.get(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
getOps.fetch_add(1);
|
|
readerOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(getLatMutex);
|
|
getLatencies.push_back(latUs);
|
|
} else {
|
|
readerFails.fetch_add(1);
|
|
}
|
|
} else {
|
|
CatRequest req;
|
|
req.hash = hash;
|
|
|
|
auto t0 = std::chrono::steady_clock::now();
|
|
auto res = yams::cli::run_sync(client.cat(req), 10s);
|
|
auto t1 = std::chrono::steady_clock::now();
|
|
auto latUs =
|
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
|
|
|
|
if (res) {
|
|
catOps.fetch_add(1);
|
|
readerOps.fetch_add(1);
|
|
std::lock_guard<std::mutex> lk(catLatMutex);
|
|
catLatencies.push_back(latUs);
|
|
} else {
|
|
readerFails.fetch_add(1);
|
|
}
|
|
}
|
|
doGet = !doGet;
|
|
|
|
std::this_thread::sleep_for(2ms);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (auto& t : writerThreads)
|
|
t.join();
|
|
done.store(true);
|
|
|
|
for (auto& t : readerThreads)
|
|
t.join();
|
|
|
|
ClientConfig monCfg;
|
|
monCfg.socketPath = harness.socketPath();
|
|
monCfg.autoStart = false;
|
|
monCfg.requestTimeout = 10s;
|
|
DaemonClient monitorClient(monCfg);
|
|
bool drained = waitForDrain(monitorClient, std::chrono::seconds(cfg.drainTimeoutSecs));
|
|
|
|
auto globalEnd = std::chrono::steady_clock::now();
|
|
double globalElapsed = std::chrono::duration<double>(globalEnd - globalStart).count();
|
|
|
|
auto getStats = PercentileStats::compute(getLatencies);
|
|
auto catStats = PercentileStats::compute(catLatencies);
|
|
|
|
std::cout << "\n=== File Access Under Ingest Load ===\n";
|
|
std::cout << " Writers: " << numWriters << " (ingested " << writerDocs.load() << " docs)\n";
|
|
std::cout << " Readers: " << numReaders << " (ops=" << readerOps.load()
|
|
<< ", attempts=" << readerAttempts.load() << ", get=" << getOps.load()
|
|
<< ", cat=" << catOps.load() << ", fails=" << readerFails.load() << ")\n";
|
|
std::cout << " Wall time: " << std::fixed << std::setprecision(2) << globalElapsed << "s\n";
|
|
printPercentiles("Get latency", getStats);
|
|
printPercentiles("Cat latency", catStats);
|
|
std::cout << " Queue drained: " << (drained ? "yes" : "NO") << "\n\n";
|
|
|
|
CHECK(writerDocs.load() > 0);
|
|
CHECK(readerOps.load() > 0);
|
|
CHECK(readerFails.load() < readerAttempts.load());
|
|
CHECK(drained);
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"test", "access_under_ingest_load"},
|
|
{"num_clients", cfg.numClients},
|
|
{"num_writers", numWriters},
|
|
{"num_readers", numReaders},
|
|
{"docs_per_writer", cfg.docsPerClient},
|
|
{"doc_size_bytes", cfg.docSizeBytes},
|
|
{"total_writer_docs", writerDocs.load()},
|
|
{"reader_attempts", readerAttempts.load()},
|
|
{"reader_ops", readerOps.load()},
|
|
{"get_ops", getOps.load()},
|
|
{"cat_ops", catOps.load()},
|
|
{"reader_fails", readerFails.load()},
|
|
{"elapsed_seconds", globalElapsed},
|
|
{"get_latency", getStats.toJson()},
|
|
{"cat_latency", catStats.toJson()},
|
|
{"drained", drained},
|
|
{"tuning_profile", cfg.tuningProfile},
|
|
};
|
|
emitJsonl(cfg.outputPath, record);
|
|
}
|