فهرست منبع

test: multi-project stress + replication load tests

Two new operator-facing load tests for the v2.3 multi-project surface.
Both pass against v2.3.1.

## tests/load_test/load_test_multi_project.cpp

Three projects (alpha, beta, gamma) run their own writer + reader
fleets concurrently against one DB instance. Verifies:
  - throughput holds up (≥600 total writes/s on default hardware)
  - zero cross-project leakage (20% of reader ops target peer-project
    ids and must get NOT_FOUND)
  - zero hard failures

Run shape (20s / 2 writers per project / 3 readers per project):
  alpha:  10799 inserts / 17324 reads / 0 leaks
  beta:   10758 inserts / 17296 reads / 0 leaks
  gamma:  10759 inserts / 17345 reads / 0 leaks
  totals: 1615 writes/s, 2598 reads/s, ZERO leaks
  on-disk: projects/{alpha,beta,gamma,default}/env/

## tests/load_test/load_test_multi_project_replication.cpp

Inserts 100 docs into each of three projects on the leader, polls the
follower per-project until caught up, then verifies cross-project
isolation on the follower side (a follower client targeting "alpha"
must NOT see "beta-doc-N" or "gamma-doc-N").

Run shape (100 docs/project against the standard replica leader+follower
config):
  alpha:  100/100 caught up in 4126ms, 0 leaks
  beta:   100/100 caught up in 4848ms, 0 leaks
  gamma:  100/100 caught up in 42ms, 0 leaks (peer fully warm)
  ZERO isolation leaks, ZERO content mismatches
  on-disk on follower: projects/{alpha,beta,gamma}/env/ all populated

## Build (standalone — same pattern as other load_test_*)

The new tests use the same build recipe as load_test_mixed:

  g++ -std=c++20 -O2 \
      -I./client/include \
      tests/load_test/load_test_multi_project.cpp \
      -L./build/client -lsmartbotic-db-client \
      $(pkg-config --libs grpc++) \
      -lspdlog -lfmt -pthread \
      -Wl,-rpath,$(pwd)/build/client \
      -o tests/load_test/load_test_multi_project

Binaries are not tracked (same as the rest of load_test/).
fszontagh 2 ماه پیش
والد
کامیت
d5f72d3405
2فایلهای تغییر یافته به همراه466 افزوده شده و 0 حذف شده
  1. 262 0
      tests/load_test/load_test_multi_project.cpp
  2. 204 0
      tests/load_test/load_test_multi_project_replication.cpp

+ 262 - 0
tests/load_test/load_test_multi_project.cpp

@@ -0,0 +1,262 @@
+// v2.3 multi-project stress test.
+//
+// Three projects (alpha, beta, gamma) run their own writer + reader
+// fleets concurrently against ONE database instance. Verifies:
+//   1) Throughput holds up (per-project writers ≥ 200/s combined ≥ 600/s)
+//   2) No cross-project leakage — a doc inserted into "alpha" never
+//      appears in a "beta" read, even when both target the same doc-id.
+//   3) Zero hard failures (RESOURCE_EXHAUSTED / DEADLINE_EXCEEDED / Other)
+//
+// Each project gets its own Client::Config so the qualify() prefix
+// path is exercised; per-call qualified form is exercised by the
+// integrity checker.
+
+#include <smartbotic/database/client.hpp>
+
+#include <atomic>
+#include <chrono>
+#include <iostream>
+#include <mutex>
+#include <random>
+#include <string>
+#include <thread>
+#include <vector>
+
+using namespace smartbotic::database;
+using Clock = std::chrono::steady_clock;
+
+namespace {
+
+struct Counters {
+    std::atomic<uint64_t> inserts{0};
+    std::atomic<uint64_t> insertFails{0};
+    std::atomic<uint64_t> reads{0};
+    std::atomic<uint64_t> readFound{0};
+    std::atomic<uint64_t> readNotFound{0};
+    std::atomic<uint64_t> readFails{0};
+    std::atomic<uint64_t> crossProjectLeaks{0};  // a doc from another project
+    std::atomic<bool> stop{false};
+};
+
+struct ProjectShard {
+    std::string name;
+    Counters counters;
+    std::mutex idsMu;
+    std::vector<std::string> recentIds;
+    std::vector<std::string> peerIds;  // doc-ids that belong to OTHER projects;
+                                        // used to verify isolation
+};
+
+void pushId(ProjectShard& s, const std::string& id) {
+    std::lock_guard<std::mutex> g(s.idsMu);
+    if (s.recentIds.size() >= 500) s.recentIds.erase(s.recentIds.begin());
+    s.recentIds.push_back(id);
+}
+
+std::optional<std::string> sampleId(ProjectShard& s, std::mt19937& rng) {
+    std::lock_guard<std::mutex> g(s.idsMu);
+    if (s.recentIds.empty()) return std::nullopt;
+    std::uniform_int_distribution<size_t> d(0, s.recentIds.size() - 1);
+    return s.recentIds[d(rng)];
+}
+
+void pushPeerId(ProjectShard& s, const std::string& id) {
+    std::lock_guard<std::mutex> g(s.idsMu);
+    if (s.peerIds.size() >= 500) s.peerIds.erase(s.peerIds.begin());
+    s.peerIds.push_back(id);
+}
+
+std::optional<std::string> samplePeerId(ProjectShard& s, std::mt19937& rng) {
+    std::lock_guard<std::mutex> g(s.idsMu);
+    if (s.peerIds.empty()) return std::nullopt;
+    std::uniform_int_distribution<size_t> d(0, s.peerIds.size() - 1);
+    return s.peerIds[d(rng)];
+}
+
+void writerLoop(ProjectShard& shard, int workerId) {
+    Client::Config cfg;
+    cfg.address = "localhost:9005";
+    cfg.timeoutMs = 5000;
+    cfg.project = shard.name;
+    Client c(cfg);
+    if (!c.connect()) {
+        std::cerr << "[" << shard.name << " writer " << workerId << "] connect failed\n";
+        return;
+    }
+    uint64_t seq = 0;
+    while (!shard.counters.stop.load(std::memory_order_relaxed)) {
+        std::string id = shard.name + "-w" + std::to_string(workerId)
+                         + "-" + std::to_string(seq);
+        try {
+            nlohmann::json doc{{"project", shard.name},
+                                {"worker", workerId},
+                                {"seq", seq}};
+            c.insert("docs", doc, id);  // qualifies to "<project>:docs"
+            shard.counters.inserts.fetch_add(1, std::memory_order_relaxed);
+            pushId(shard, id);
+        } catch (const std::exception&) {
+            shard.counters.insertFails.fetch_add(1, std::memory_order_relaxed);
+        }
+        ++seq;
+    }
+}
+
+void readerLoop(ProjectShard& shard, int workerId) {
+    Client::Config cfg;
+    cfg.address = "localhost:9005";
+    cfg.timeoutMs = 5000;
+    cfg.project = shard.name;
+    Client c(cfg);
+    if (!c.connect()) {
+        std::cerr << "[" << shard.name << " reader " << workerId << "] connect failed\n";
+        return;
+    }
+    std::mt19937 rng(std::random_device{}() + workerId);
+    while (!shard.counters.stop.load(std::memory_order_relaxed)) {
+        // 80% of the time read our own doc; 20% try a peer's id and
+        // verify we get NOT_FOUND (cross-project isolation check).
+        std::uniform_int_distribution<int> dice(0, 9);
+        if (dice(rng) < 8) {
+            auto idOpt = sampleId(shard, rng);
+            if (!idOpt) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); continue; }
+            try {
+                auto doc = c.get("docs", *idOpt);
+                shard.counters.reads.fetch_add(1, std::memory_order_relaxed);
+                if (doc) shard.counters.readFound.fetch_add(1);
+                else     shard.counters.readNotFound.fetch_add(1);
+            } catch (const std::exception&) {
+                shard.counters.readFails.fetch_add(1);
+            }
+        } else {
+            auto peerOpt = samplePeerId(shard, rng);
+            if (!peerOpt) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); continue; }
+            try {
+                auto doc = c.get("docs", *peerOpt);
+                shard.counters.reads.fetch_add(1);
+                if (doc) {
+                    // ISOLATION BREACH — we should NOT see another project's doc.
+                    shard.counters.crossProjectLeaks.fetch_add(1);
+                    std::cerr << "[" << shard.name << " reader " << workerId
+                              << "] LEAK: saw foreign id " << *peerOpt << "\n";
+                } else {
+                    shard.counters.readNotFound.fetch_add(1);
+                }
+            } catch (const std::exception&) {
+                shard.counters.readFails.fetch_add(1);
+            }
+        }
+    }
+}
+
+}  // namespace
+
+int main(int argc, char* argv[]) {
+    int durationSec = (argc > 1) ? std::stoi(argv[1]) : 20;
+    int writersPerProject = (argc > 2) ? std::stoi(argv[2]) : 2;
+    int readersPerProject = (argc > 3) ? std::stoi(argv[3]) : 3;
+
+    std::cout << "=== v2.3 multi-project stress ===\n"
+              << "Duration:      " << durationSec << "s\n"
+              << "Projects:      alpha, beta, gamma\n"
+              << "Writers/proj:  " << writersPerProject << "\n"
+              << "Readers/proj:  " << readersPerProject << "\n\n";
+
+    // One bootstrap client to ensure projects exist; not strictly required
+    // (per-write would lazy-create) but the explicit createProject also
+    // exercises that RPC.
+    {
+        Client::Config c;
+        c.address = "localhost:9005";
+        Client bootstrap(c);
+        if (!bootstrap.connect()) {
+            std::cerr << "bootstrap connect failed\n";
+            return 1;
+        }
+        for (const char* p : {"alpha", "beta", "gamma"}) {
+            try { bootstrap.createProject(p); }
+            catch (const std::exception& e) {
+                std::cerr << "createProject(" << p << ") failed: " << e.what() << "\n";
+            }
+        }
+    }
+
+    std::vector<std::unique_ptr<ProjectShard>> shards;
+    for (const auto& name : {"alpha", "beta", "gamma"}) {
+        auto s = std::make_unique<ProjectShard>();
+        s->name = name;
+        shards.push_back(std::move(s));
+    }
+
+    // Cross-pollinate peer ids: each shard's peerIds = all OTHER shards'
+    // ids as they come in. Since shards are concurrent, use a periodic
+    // cross-fill thread.
+    std::vector<std::thread> threads;
+    for (auto& sp : shards) {
+        ProjectShard& s = *sp;
+        for (int w = 0; w < writersPerProject; ++w) threads.emplace_back(writerLoop, std::ref(s), w);
+        for (int r = 0; r < readersPerProject; ++r) threads.emplace_back(readerLoop, std::ref(s), r);
+    }
+
+    // Cross-fill: every 500ms, copy 5 ids from each shard into the OTHER
+    // shards' peerIds list. Readers sample from peerIds for isolation
+    // probes.
+    std::thread crossfill([&]() {
+        while (!shards[0]->counters.stop.load()) {
+            std::this_thread::sleep_for(std::chrono::milliseconds(500));
+            for (size_t i = 0; i < shards.size(); ++i) {
+                std::vector<std::string> snap;
+                {
+                    std::lock_guard<std::mutex> g(shards[i]->idsMu);
+                    int take = std::min<int>(5, shards[i]->recentIds.size());
+                    for (int k = 0; k < take; ++k) snap.push_back(shards[i]->recentIds[k]);
+                }
+                for (size_t j = 0; j < shards.size(); ++j) {
+                    if (i == j) continue;
+                    for (const auto& id : snap) pushPeerId(*shards[j], id);
+                }
+            }
+        }
+    });
+
+    std::this_thread::sleep_for(std::chrono::seconds(durationSec));
+    for (auto& sp : shards) sp->counters.stop.store(true);
+    for (auto& t : threads) t.join();
+    crossfill.join();
+
+    std::cout << "\n=== Per-project results ===\n";
+    bool any_leak = false;
+    bool any_hard_fail = false;
+    uint64_t total_inserts = 0;
+    uint64_t total_reads = 0;
+    for (auto& sp : shards) {
+        ProjectShard& s = *sp;
+        std::cout << "[" << s.name << "]"
+                  << "  inserts=" << s.counters.inserts.load()
+                  << "  insertFails=" << s.counters.insertFails.load()
+                  << "  reads=" << s.counters.reads.load()
+                  << "  found=" << s.counters.readFound.load()
+                  << "  not-found=" << s.counters.readNotFound.load()
+                  << "  readFails=" << s.counters.readFails.load()
+                  << "  leaks=" << s.counters.crossProjectLeaks.load()
+                  << "\n";
+        total_inserts += s.counters.inserts.load();
+        total_reads += s.counters.reads.load();
+        if (s.counters.crossProjectLeaks.load() > 0) any_leak = true;
+        if (s.counters.readFails.load() > 0) any_hard_fail = true;
+    }
+
+    const double total_w_rate = static_cast<double>(total_inserts) / durationSec;
+    const double total_r_rate = static_cast<double>(total_reads) / durationSec;
+
+    std::cout << "\nTotal writers ops/s: " << static_cast<uint64_t>(total_w_rate) << "\n";
+    std::cout << "Total readers ops/s: " << static_cast<uint64_t>(total_r_rate) << "\n";
+
+    std::cout << "\n=== Verdict ===\n";
+    std::cout << "  no cross-project leaks:      " << (any_leak ? "FAIL" : "PASS") << "\n";
+    std::cout << "  no hard read failures:       " << (any_hard_fail ? "FAIL" : "PASS") << "\n";
+    std::cout << "  total writers > 600/s:       "
+              << (total_w_rate > 600 ? "PASS" : "FAIL")
+              << " (actual " << static_cast<uint64_t>(total_w_rate) << ")\n";
+
+    return (any_leak || any_hard_fail || total_w_rate <= 600) ? 1 : 0;
+}

+ 204 - 0
tests/load_test/load_test_multi_project_replication.cpp

@@ -0,0 +1,204 @@
+// v2.3 multi-project replication test.
+//
+// Inserts docs into three projects (alpha, beta, gamma) on the leader,
+// polls the follower until each project's count catches up, then
+// verifies isolation: a follower client targeting "alpha" must NOT
+// see docs whose id was created in "beta" or "gamma".
+//
+// Talks to localhost:9005 (leader) + localhost:9006 (follower); same
+// orchestrator script as load_test_replication can spin those up.
+//
+// Exit codes:
+//   0 — PASS (all three projects caught up + isolated)
+//   1 — FAIL (catch-up timed out OR cross-project leakage)
+//   2 — PARTIAL (some but not all projects caught up)
+
+#include <smartbotic/database/client.hpp>
+
+#include <chrono>
+#include <cstdio>
+#include <iostream>
+#include <string>
+#include <thread>
+#include <vector>
+
+using namespace smartbotic::database;
+using Clock = std::chrono::steady_clock;
+
+namespace {
+
+struct ProjectResult {
+    std::string project;
+    int inserted = 0;
+    int insert_failures = 0;
+    int seen_on_follower = 0;
+    int content_mismatches = 0;
+    int catchup_ms = -1;  // -1 = never caught up
+    int isolation_breaches = 0;
+};
+
+ProjectResult run_project(const std::string& project,
+                           int numDocs,
+                           int maxWaitSec) {
+    ProjectResult r;
+    r.project = project;
+
+    Client::Config lcfg;
+    lcfg.address = "localhost:9005";
+    lcfg.timeoutMs = 3000;
+    lcfg.project = project;
+    Client leader(lcfg);
+    if (!leader.connect()) {
+        std::cerr << "[" << project << "] leader connect failed\n";
+        return r;
+    }
+
+    Client::Config fcfg = lcfg;
+    fcfg.address = "localhost:9006";
+    fcfg.project = project;
+    Client follower(fcfg);
+    if (!follower.connect()) {
+        std::cerr << "[" << project << "] follower connect failed\n";
+        return r;
+    }
+
+    // Phase 1: insert numDocs on leader.
+    for (int i = 0; i < numDocs; i++) {
+        std::string id = project + "-doc-" + std::to_string(i);
+        nlohmann::json doc{{"project", project},
+                            {"i", i},
+                            {"content", project + "-content-" + std::to_string(i)}};
+        try {
+            leader.insert("repl", doc, id);
+            r.inserted++;
+        } catch (const std::exception&) {
+            r.insert_failures++;
+        }
+    }
+
+    // Phase 2: poll follower until caught up or timeout.
+    auto start = Clock::now();
+    while (true) {
+        auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
+            Clock::now() - start).count();
+        if (elapsed > maxWaitSec) break;
+
+        // Sample a known id; if it's there, count it. (Counting via Find
+        // would be cleaner but Find paging adds noise; per-id Get is
+        // deterministic.)
+        int seen = 0;
+        int mismatches = 0;
+        for (int i = 0; i < numDocs; i++) {
+            std::string id = project + "-doc-" + std::to_string(i);
+            try {
+                auto doc = follower.get("repl", id);
+                if (doc) {
+                    seen++;
+                    if ((*doc).value("content", "") !=
+                        std::string(project + "-content-" + std::to_string(i))) {
+                        mismatches++;
+                    }
+                }
+            } catch (...) { /* tolerate transient */ }
+        }
+        if (seen == numDocs) {
+            r.seen_on_follower = seen;
+            r.content_mismatches = mismatches;
+            r.catchup_ms = static_cast<int>(
+                std::chrono::duration_cast<std::chrono::milliseconds>(
+                    Clock::now() - start).count());
+            break;
+        }
+        std::this_thread::sleep_for(std::chrono::milliseconds(200));
+    }
+
+    // Phase 3: isolation check — try to fetch OTHER projects' ids
+    // through this project's follower client. Should all return absent.
+    for (const std::string& other : {"alpha", "beta", "gamma"}) {
+        if (other == project) continue;
+        for (int i = 0; i < std::min(numDocs, 20); i++) {
+            std::string id = other + "-doc-" + std::to_string(i);
+            try {
+                auto doc = follower.get("repl", id);
+                if (doc) {
+                    r.isolation_breaches++;
+                    std::cerr << "[" << project << "] LEAK: follower returned "
+                              << "foreign id '" << id << "'\n";
+                }
+            } catch (...) { /* tolerate */ }
+        }
+    }
+
+    return r;
+}
+
+}  // namespace
+
+int main(int argc, char* argv[]) {
+    int numDocs = (argc > 1) ? std::stoi(argv[1]) : 100;
+    int maxWaitSec = (argc > 2) ? std::stoi(argv[2]) : 30;
+
+    std::cout << "=== v2.3 multi-project replication sanity ===\n"
+              << "Leader:        localhost:9005\n"
+              << "Follower:      localhost:9006\n"
+              << "Projects:      alpha, beta, gamma (parallel)\n"
+              << "Docs/project:  " << numDocs << "\n"
+              << "Max wait:      " << maxWaitSec << "s/project\n\n";
+
+    // Ensure projects exist on BOTH leader and follower. (Server-side
+    // they auto-create on first write, but the project list RPC needs
+    // them; harmless to call.)
+    for (const char* p : {"alpha", "beta", "gamma"}) {
+        for (const char* addr : {"localhost:9005", "localhost:9006"}) {
+            Client::Config c;
+            c.address = addr;
+            Client bootstrap(c);
+            if (bootstrap.connect()) {
+                try { bootstrap.createProject(p); }
+                catch (...) { /* idempotent — already-exists is fine */ }
+            }
+        }
+    }
+
+    // Run all three projects sequentially. (Concurrent would stress more
+    // but sequential is enough for correctness — we still exercise the
+    // multi-project replication routing on every run.)
+    std::vector<ProjectResult> results;
+    for (const std::string& p : {"alpha", "beta", "gamma"}) {
+        std::cout << "--- " << p << " ---\n";
+        auto r = run_project(p, numDocs, maxWaitSec);
+        std::cout << "  inserted="     << r.inserted
+                  << " failures="      << r.insert_failures
+                  << " seen="          << r.seen_on_follower
+                  << " mismatches="    << r.content_mismatches
+                  << " catchup_ms="    << r.catchup_ms
+                  << " leaks="         << r.isolation_breaches << "\n";
+        results.push_back(std::move(r));
+    }
+
+    int caught_up = 0;
+    int leaks = 0;
+    int mismatches = 0;
+    for (auto& r : results) {
+        if (r.catchup_ms >= 0 && r.seen_on_follower == r.inserted) caught_up++;
+        leaks += r.isolation_breaches;
+        mismatches += r.content_mismatches;
+    }
+
+    std::cout << "\n=== Verdict ===\n"
+              << "  projects caught up: " << caught_up << "/3\n"
+              << "  isolation leaks:    " << leaks << "\n"
+              << "  content mismatches: " << mismatches << "\n";
+
+    if (caught_up == 3 && leaks == 0 && mismatches == 0) {
+        std::cout << "Verdict: PASS\n";
+        return 0;
+    }
+    if (caught_up > 0 && leaks == 0 && mismatches == 0) {
+        std::cout << "Verdict: PARTIAL — " << caught_up
+                  << "/3 caught up, no corruption\n";
+        return 2;
+    }
+    std::cout << "Verdict: FAIL\n";
+    return 1;
+}