Pārlūkot izejas kodu

test(load): replicated eviction — tight follower

Leader (128 MB) ingests 5000 x 2 KB docs = ~12 MB payload. Follower
(16 MB) must accept every replication entry and evict to make room.
Verifies:
  - follower receives all N docs (no RESOURCE_EXHAUSTED on the apply path)
  - follower evicts (since data > cap)
  - every doc still readable on follower via live-map or WAL-fallback
  - content byte-exact after the replicate -> evict -> page-in round trip

Catches bugs like:
  - replication accidentally gated by admission control
  - evicted-on-follower docs unreadable (eviction took a path that
    skipped the WAL)
  - content corruption across the round trip

First run against v1.7.1 found a real bug: applyReplicatedEntry calls
store_->loadDocument() to avoid re-broadcasting, but that path also
skips the local WAL append. When the follower evicts, the recovery
callback scans the WAL and finds nothing — 5000/5000 docs permanently
unreadable after eviction. Bug + reproduction documented in the README
section for Test 8.
fszontagh 3 mēneši atpakaļ
vecāks
revīzija
5b9a7ba054

+ 91 - 0
tests/load_test/README.md

@@ -337,3 +337,94 @@ Check both server logs for the peer handshake:
     "Connected to peer 127.0.0.1:9005 (node: replica-leader, ...)"
 
 Without both lines, peering never established — likely a firewall or port conflict. `ss -tlnp | grep 900[56]` will confirm both are listening.
+
+---
+
+## Test 8 — Replicated eviction (`run_replica_eviction_test.sh`)
+
+The follower has a deliberately tighter memory cap than the leader. The leader ingests enough data that the follower cannot hold it all in RAM, so the follower must:
+
+1. Accept every replication entry (no `RESOURCE_EXHAUSTED` on the apply path — that would break sync forever)
+2. Evict older docs to make room
+3. Still serve every replicated doc correctly on `get()`, paging evicted ones back in from the WAL
+
+This test catches bugs that only appear under the combined replicate + evict + page-in round trip:
+
+- follower's replication-apply accidentally gated by admission control
+- evicted-on-follower docs unreadable because replication took a shortcut past the normal WAL-write path
+- content corruption across the `replicate -> evict -> page-in` cycle
+
+### Build
+
+    cd tests/load_test
+    g++ -std=c++20 -O2 -I/usr/include load_test_replica_eviction.cpp \
+        -lsmartbotic-db-client $(pkg-config --libs grpc++) \
+        -lspdlog -lfmt -pthread -o load_test_replica_eviction
+    chmod +x run_replica_eviction_test.sh
+
+### Run
+
+    ./run_replica_eviction_test.sh [num_docs=5000] [stabilize_sec=5]
+
+### Configs
+
+- `config_replica_leader_128mb.json` — port 9005, `max_memory_mb: 128` (plenty of room), replication enabled, peer = `127.0.0.1:9006`, data dir `/tmp/smartbotic-loadtest-replica-eviction-leader/data`
+- `config_replica_follower_16mb.json` — port 9006, `max_memory_mb: 16` (tight — forces eviction), soft/hard/emergency = 50/70/90, replication enabled, peer = `127.0.0.1:9005`, data dir `/tmp/smartbotic-loadtest-replica-eviction-follower/data`
+
+### Workload
+
+- 5000 docs x ~2 KB (each has a 1900-byte `pad` field) inserted on the leader
+- In memory terms (~2.5x overhead per v1.7.0 `estimateDocumentSize`) = ~25 MB on each side
+- Leader (128 MB): no pressure
+- Follower (16 MB): ~150% over cap -> eviction fires continuously
+
+### PASS criteria
+
+- All 5000 docs readable on the follower (live-map + WAL-fallback both exercised)
+- Zero content mismatches, zero read errors
+- `evictedStubCount > 0` (proves eviction fired — if not, the test is INCONCLUSIVE, raise `num_docs` or lower follower cap)
+
+### Exit codes
+
+- `0` — PASS (all docs replicated + byte-exact, eviction fired on follower)
+- `1` — FAIL (missing docs or content corruption)
+- `2` — INCONCLUSIVE (eviction never fired — follower had room to spare)
+
+### Known bug: evicted-on-follower docs not recoverable (as of v1.7.1)
+
+First run of this test against v1.7.1 produced **FAIL — all 5000 docs replicated (follower shows 5000 evicted stubs), but zero readable on follower**. Server log shows `"Failed to recover evicted document replica_evict/repl-<i>"` for every id.
+
+Root cause is in `DatabaseService::applyReplicatedEntry` at `service/src/database_service.cpp:623`:
+
+    // Use loadDocument to bypass normal callbacks (avoid re-replication)
+    store_->loadDocument(entry.collection(), doc);
+
+`MemoryStore::loadDocument` only inserts the doc into the in-memory map — it deliberately skips the usual callback chain so the follower does not re-broadcast. But that chain is also what appends the doc to the **local** WAL. So on the follower the doc lives only in RAM.
+
+When eviction then fires on the follower, the doc is dropped to an evicted-stub. On read, the recovery callback calls `persistence_->loadDocument()` which scans the follower's WAL — and finds nothing, because replication never wrote it there. Result: permanent data loss the instant the follower evicts, even though the stub still reports the doc as "accounted for".
+
+Fix options (not made in this changeset — the test documents the bug):
+
+1. In `applyReplicatedEntry`, after `store_->loadDocument(...)`, append to the local WAL (e.g. `persistence_->appendInsert(collection, doc)` / `appendUpsert` depending on op), so evicted-on-follower docs have a durable backing store. Need to make sure this does not trigger re-replication — either a separate WAL-only path or a flag on `loadDocument` that writes to WAL but suppresses the replication-broadcast callback.
+2. Alternatively, teach the eviction callback to fall back to pulling from a peer when the local WAL has no entry — more complex, adds a network hop on cold read.
+
+Both leader's broadcast *and* follower's `loadDocument` side were intentionally separated to avoid a replication loop; option (1) is the less invasive of the two.
+
+### Test output the bug produces
+
+    Phase 2: waiting 5s for follower to stabilize...
+      [t+2000ms] follower pressure=emergency (100%) live=0 evicted=5000
+      ...
+    Phase 3: reading all 5000 docs from follower...
+      not-found id=repl-0
+      not-found id=repl-1
+      ...
+    Summary: Found: 0, Not found: 5000
+    Verdict: FAIL
+
+And in the follower server log:
+
+    [warning] Failed to recover evicted document replica_evict/repl-4999
+
+This confirms replication worked (5000 stubs exist on the follower and their IDs match what the leader inserted) — but the evicted-stub recovery path has nothing to page in from.
+

+ 58 - 0
tests/load_test/config_replica_follower_16mb.json

@@ -0,0 +1,58 @@
+{
+  "log_level": "info",
+  "storage": {
+    "bind_address": "127.0.0.1",
+    "rpc_port": 9006,
+    "node_id": "replica-follower-16",
+    "data_directory": "/tmp/smartbotic-loadtest-replica-eviction-follower/data",
+    "memory": {
+      "max_memory_mb": 16,
+      "eviction_threshold_percent": 70,
+      "eviction_target_percent": 40,
+      "eviction_check_interval_ms": 200,
+      "eviction_chunk_size": 500,
+      "eviction_chunk_pause_ms": 30,
+      "max_eviction_passes_per_trigger": 15,
+      "hot_write_floor_ms": 2000,
+      "memory_soft_percent": 50,
+      "memory_hard_percent": 70,
+      "memory_emergency_percent": 90,
+      "eviction_burst_threshold": 2000
+    },
+    "persistence": {
+      "wal_sync_interval_ms": 100,
+      "snapshot_interval_sec": 3600,
+      "compression": "lz4",
+      "snapshots": {
+        "validate_after_write": true,
+        "cleanup_only_if_verified": true
+      },
+      "recovery": {
+        "mode": "normal",
+        "auto_escalate": true,
+        "allow_empty_on_fresh_install": true
+      }
+    },
+    "encryption": {
+      "enabled": false
+    },
+    "migrations": {
+      "enabled": false
+    },
+    "files": {
+      "max_file_size_mb": 100
+    },
+    "replication": {
+      "enabled": true,
+      "conflict_resolution": "last_writer_wins",
+      "peers": ["127.0.0.1:9005"]
+    },
+    "grpc": {
+      "max_receive_message_size_mb": 100,
+      "max_send_message_size_mb": 100,
+      "resource_quota_memory_mb": 64,
+      "max_concurrent_subscribe_streams": 50,
+      "max_concurrent_file_streams": 10
+    }
+  }
+}

+ 58 - 0
tests/load_test/config_replica_leader_128mb.json

@@ -0,0 +1,58 @@
+{
+  "log_level": "info",
+  "storage": {
+    "bind_address": "127.0.0.1",
+    "rpc_port": 9005,
+    "node_id": "replica-leader-128",
+    "data_directory": "/tmp/smartbotic-loadtest-replica-eviction-leader/data",
+    "memory": {
+      "max_memory_mb": 128,
+      "eviction_threshold_percent": 80,
+      "eviction_target_percent": 50,
+      "eviction_check_interval_ms": 200,
+      "eviction_chunk_size": 500,
+      "eviction_chunk_pause_ms": 30,
+      "max_eviction_passes_per_trigger": 15,
+      "hot_write_floor_ms": 2000,
+      "memory_soft_percent": 60,
+      "memory_hard_percent": 80,
+      "memory_emergency_percent": 92,
+      "eviction_burst_threshold": 2000
+    },
+    "persistence": {
+      "wal_sync_interval_ms": 100,
+      "snapshot_interval_sec": 3600,
+      "compression": "lz4",
+      "snapshots": {
+        "validate_after_write": true,
+        "cleanup_only_if_verified": true
+      },
+      "recovery": {
+        "mode": "normal",
+        "auto_escalate": true,
+        "allow_empty_on_fresh_install": true
+      }
+    },
+    "encryption": {
+      "enabled": false
+    },
+    "migrations": {
+      "enabled": false
+    },
+    "files": {
+      "max_file_size_mb": 100
+    },
+    "replication": {
+      "enabled": true,
+      "conflict_resolution": "last_writer_wins",
+      "peers": ["127.0.0.1:9006"]
+    },
+    "grpc": {
+      "max_receive_message_size_mb": 100,
+      "max_send_message_size_mb": 100,
+      "resource_quota_memory_mb": 128,
+      "max_concurrent_subscribe_streams": 50,
+      "max_concurrent_file_streams": 10
+    }
+  }
+}

+ 230 - 0
tests/load_test/load_test_replica_eviction.cpp

@@ -0,0 +1,230 @@
+// Test 8 — Replicated eviction (tight follower)
+//
+// The follower has a tighter memory cap than the leader. When the leader
+// ingests more data than the follower can hold in memory, the follower
+// must:
+//   1. Accept every replication entry (not refuse with RESOURCE_EXHAUSTED,
+//      which would break sync forever)
+//   2. Evict older docs to make room
+//   3. Still return every replicated doc correctly on get() — via
+//      WAL-fallback for evicted ones
+//
+// This test catches bugs like:
+//   - Follower's replication-apply path inadvertently gated by admission
+//     control
+//   - Evicted-on-follower docs unreadable because replication took a
+//     shortcut past the normal WAL-write path
+//   - Content corruption across replicate -> evict -> page-in round trip
+//
+// Exit codes:
+//   0 — PASS         (all docs replicated + byte-exact, eviction fired)
+//   1 — FAIL         (missing docs or content corruption)
+//   2 — INCONCLUSIVE (eviction never fired; follower too roomy)
+
+#include <smartbotic/database/client.hpp>
+
+#include <chrono>
+#include <cstdlib>
+#include <iostream>
+#include <string>
+#include <thread>
+
+using namespace smartbotic::database;
+using Clock = std::chrono::steady_clock;
+
+int main(int argc, char* argv[]) {
+    int numDocs = 5000;
+    int stabilizeSec = 5;
+    if (argc > 1) numDocs = std::stoi(argv[1]);
+    if (argc > 2) stabilizeSec = std::stoi(argv[2]);
+
+    std::cout << "=== Replicated eviction test ===\n"
+              << "Leader (128 MB):   localhost:9005\n"
+              << "Follower (16 MB):  localhost:9006\n"
+              << "Docs:              " << numDocs << " x ~2 KB\n"
+              << "Stabilize:         " << stabilizeSec << "s\n\n";
+
+    Client::Config lcfg;
+    lcfg.address = "localhost:9005";
+    lcfg.timeoutMs = 5000;
+    Client leader(lcfg);
+    if (!leader.connect()) {
+        std::cerr << "leader connect failed\n";
+        return 1;
+    }
+
+    Client::Config fcfg = lcfg;
+    fcfg.address = "localhost:9006";
+    Client follower(fcfg);
+    if (!follower.connect()) {
+        std::cerr << "follower connect failed\n";
+        return 1;
+    }
+
+    try {
+        leader.createCollection("replica_evict");
+    } catch (const std::exception& e) {
+        std::cout << "leader createCollection note: " << e.what() << "\n";
+    }
+
+    // Content generator: 1900-byte pad forces real memory pressure.
+    auto makeContent = [](int i) -> nlohmann::json {
+        std::string pad(1900, 'x');
+        return {
+            {"seq", i},
+            {"content", "repl-" + std::to_string(i)},
+            {"pad", pad}
+        };
+    };
+
+    // -------- Phase 1: insert on leader --------
+    std::cout << "Phase 1: inserting " << numDocs << " docs on leader...\n";
+    auto t0 = Clock::now();
+    int inserted = 0;
+    int insertFailed = 0;
+    for (int i = 0; i < numDocs; ++i) {
+        try {
+            leader.insert("replica_evict", makeContent(i), "repl-" + std::to_string(i));
+            ++inserted;
+        } catch (const std::exception& e) {
+            ++insertFailed;
+            if (insertFailed <= 5) {
+                std::cout << "  leader insert failed id=repl-" << i
+                          << " err=" << e.what() << "\n";
+            }
+        }
+        if ((i + 1) % 500 == 0) {
+            auto lstats = leader.getMemoryStats();
+            auto fstats = follower.getMemoryStats();
+            std::cout << "  inserted " << (i + 1)
+                      << "  leader=" << lstats.pressurePercent
+                      << "% (" << lstats.pressureLevel << ")"
+                      << "  follower=" << fstats.pressurePercent
+                      << "% (" << fstats.pressureLevel << ")\n";
+        }
+    }
+    auto insertMs = std::chrono::duration_cast<std::chrono::milliseconds>(
+        Clock::now() - t0).count();
+    std::cout << "  insert done: " << inserted << "/" << numDocs
+              << " in " << insertMs << "ms ("
+              << insertFailed << " failed)\n\n";
+
+    // -------- Phase 2: wait for follower to stabilize --------
+    std::cout << "Phase 2: waiting " << stabilizeSec
+              << "s for follower to stabilize...\n";
+    for (int s = 0; s < stabilizeSec * 2; ++s) {
+        auto fstats = follower.getMemoryStats();
+        uint64_t fLive = 0, fEvicted = 0;
+        for (const auto& c : fstats.collections) {
+            if (c.collection == "replica_evict") {
+                fLive = c.documentCount;
+                fEvicted = c.evictedStubCount;
+            }
+        }
+        std::cout << "  [t+" << (s * 500) << "ms] follower pressure="
+                  << fstats.pressureLevel
+                  << " (" << fstats.pressurePercent << "%)"
+                  << " live=" << fLive
+                  << " evicted=" << fEvicted << "\n";
+        std::this_thread::sleep_for(std::chrono::milliseconds(500));
+    }
+    std::cout << "\n";
+
+    // -------- Phase 3: verify every doc on follower --------
+    std::cout << "Phase 3: reading all " << numDocs
+              << " docs from follower...\n";
+    int found = 0;
+    int contentMatches = 0;
+    int contentMismatches = 0;
+    int notFound = 0;
+    int readErrors = 0;
+    auto t2 = Clock::now();
+    for (int i = 0; i < numDocs; ++i) {
+        std::optional<nlohmann::json> doc;
+        try {
+            doc = follower.get("replica_evict", "repl-" + std::to_string(i));
+        } catch (const std::exception& e) {
+            ++readErrors;
+            if (readErrors <= 5) {
+                std::cout << "  read error id=repl-" << i
+                          << " err=" << e.what() << "\n";
+            }
+            continue;
+        }
+        if (!doc) {
+            ++notFound;
+            if (notFound <= 5) {
+                std::cout << "  not-found id=repl-" << i << "\n";
+            }
+            continue;
+        }
+        ++found;
+        if (doc->contains("content") &&
+            (*doc)["content"].is_string() &&
+            (*doc)["content"].get<std::string>() == ("repl-" + std::to_string(i)) &&
+            doc->contains("seq") &&
+            (*doc)["seq"].is_number_integer() &&
+            (*doc)["seq"].get<int>() == i) {
+            ++contentMatches;
+        } else {
+            ++contentMismatches;
+            if (contentMismatches <= 5) {
+                std::cout << "  mismatch id=repl-" << i
+                          << " body=" << doc->dump().substr(0, 240) << "\n";
+            }
+        }
+    }
+    auto readMs = std::chrono::duration_cast<std::chrono::milliseconds>(
+        Clock::now() - t2).count();
+    if (readMs == 0) readMs = 1;
+
+    auto finalFollowerStats = follower.getMemoryStats();
+    uint64_t fLiveFinal = 0, fEvictedFinal = 0;
+    for (const auto& c : finalFollowerStats.collections) {
+        if (c.collection == "replica_evict") {
+            fLiveFinal = c.documentCount;
+            fEvictedFinal = c.evictedStubCount;
+        }
+    }
+
+    std::cout << "\n=== Summary ===\n"
+              << "Inserted on leader:       " << inserted << "/" << numDocs << "\n"
+              << "Follower read results:\n"
+              << "  Found:                  " << found << "\n"
+              << "  Not found:              " << notFound << "\n"
+              << "  Read errors:            " << readErrors << "\n"
+              << "  Content matches:        " << contentMatches << "\n"
+              << "  Content mismatches:     " << contentMismatches << "\n"
+              << "Final follower state:\n"
+              << "  Live docs:              " << fLiveFinal << "\n"
+              << "  Evicted stubs:          " << fEvictedFinal << "\n"
+              << "  Total accounted:        " << (fLiveFinal + fEvictedFinal) << "\n"
+              << "  Pressure level:         " << finalFollowerStats.pressureLevel
+              << " (" << finalFollowerStats.pressurePercent << "%)\n"
+              << "Read duration:            " << readMs << "ms ("
+              << (numDocs * 1000 / readMs) << " ops/s)\n\n";
+
+    // Verdict
+    bool allReplicated = (found == inserted);
+    bool allContentOk  = (contentMismatches == 0 && readErrors == 0);
+    bool evictionFired = (fEvictedFinal > 0);
+
+    if (allReplicated && allContentOk && evictionFired) {
+        std::cout << "Verdict: PASS — all " << inserted
+                  << " docs replicated, eviction fired on follower ("
+                  << fEvictedFinal
+                  << " stubs), content intact via WAL-fallback\n";
+        return 0;
+    }
+    if (allReplicated && allContentOk && !evictionFired) {
+        std::cout << "Verdict: INCONCLUSIVE — follower had enough room; "
+                  << "eviction never fired. Raise numDocs or lower follower cap.\n";
+        return 2;
+    }
+    std::cout << "Verdict: FAIL — "
+              << "found=" << found << "/" << inserted
+              << " mismatches=" << contentMismatches
+              << " notFound=" << notFound
+              << " readErrors=" << readErrors << "\n";
+    return 1;
+}

+ 98 - 0
tests/load_test/run_replica_eviction_test.sh

@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+# Test 8 — Replicated eviction orchestrator.
+#
+# 1) Wipe data dirs for the tight-follower scenario.
+# 2) Start leader (128 MB, port 9005) and follower (16 MB, port 9006).
+# 3) Verify both listening.
+# 4) Run load_test_replica_eviction: insert 5000 x 2KB docs on leader,
+#    wait for follower to stabilize under pressure, verify every doc
+#    readable + byte-exact.
+# 5) Always tail both logs for diagnosis — look for "Eviction chunk" on
+#    the follower side.
+#
+# Usage:   ./run_replica_eviction_test.sh [num_docs] [stabilize_sec]
+# Exit:    0 PASS, 1 FAIL, 2 INCONCLUSIVE, other = orchestration error.
+
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+LDIR=/tmp/smartbotic-loadtest-replica-eviction-leader
+FDIR=/tmp/smartbotic-loadtest-replica-eviction-follower
+
+NUM_DOCS="${1:-5000}"
+STABILIZE_SEC="${2:-5}"
+
+echo "=== Phase 0: reset data dirs ==="
+rm -rf "$LDIR" "$FDIR"
+mkdir -p "$LDIR/data" "$LDIR/files" "$FDIR/data" "$FDIR/files"
+
+LPID=""
+FPID=""
+cleanup() {
+    if [[ -n "${LPID:-}" ]]; then kill "$LPID" 2>/dev/null || true; fi
+    if [[ -n "${FPID:-}" ]]; then kill "$FPID" 2>/dev/null || true; fi
+}
+trap cleanup EXIT
+
+echo ""
+echo "=== Phase 1: start leader on port 9005 (128 MB cap) ==="
+smartbotic-database --config ./config_replica_leader_128mb.json \
+    > "$LDIR/server.log" 2>&1 &
+LPID=$!
+sleep 2
+
+if ! kill -0 "$LPID" 2>/dev/null; then
+    echo "leader failed to start:"
+    tail -40 "$LDIR/server.log"
+    exit 1
+fi
+
+echo ""
+echo "=== Phase 2: start follower on port 9006 (16 MB cap) ==="
+smartbotic-database --config ./config_replica_follower_16mb.json \
+    > "$FDIR/server.log" 2>&1 &
+FPID=$!
+sleep 2
+
+if ! kill -0 "$FPID" 2>/dev/null; then
+    echo "follower failed to start:"
+    tail -40 "$FDIR/server.log"
+    exit 1
+fi
+
+if ! ss -tlnp 2>/dev/null | grep -q ":9005 "; then
+    echo "leader not listening on :9005"
+    tail -40 "$LDIR/server.log"
+    exit 1
+fi
+if ! ss -tlnp 2>/dev/null | grep -q ":9006 "; then
+    echo "follower not listening on :9006"
+    tail -40 "$FDIR/server.log"
+    exit 1
+fi
+
+echo "waiting 6s for peer handshake..."
+sleep 6
+
+echo ""
+echo "=== Phase 3: run load_test_replica_eviction ==="
+set +e
+./load_test_replica_eviction "$NUM_DOCS" "$STABILIZE_SEC"
+VERDICT=$?
+set -e
+
+echo ""
+echo "--- leader log tail (last 10 lines) ---"
+tail -10 "$LDIR/server.log" || true
+echo ""
+echo "--- follower log tail (last 25 lines — look for Eviction chunks) ---"
+tail -25 "$FDIR/server.log" || true
+
+echo ""
+case "$VERDICT" in
+    0) echo "=== Orchestrator: PASS ===" ;;
+    2) echo "=== Orchestrator: INCONCLUSIVE (exit=$VERDICT) ===" ;;
+    *) echo "=== Orchestrator: FAIL (exit=$VERDICT) ===" ;;
+esac
+exit "$VERDICT"