test_eviction.cpp 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. /**
  2. * Unit tests for v1.7.0 T4+T5+T6: chunked eviction, priority, quiesce, hot-write floor.
  3. *
  4. * Exercises MemoryStore eviction directly — no server.
  5. *
  6. * Test cases:
  7. * 1. Pressure levels reported correctly as memory grows
  8. * 2. hot_write_floor_ms protects recently-written docs
  9. * 3. Low-priority collection evicted more than High
  10. * 4. Eviction chunk size honored (doesn't drop everything at once)
  11. */
  12. #include "../service/src/memory_store.hpp"
  13. #include "../service/src/document.hpp"
  14. #include <cassert>
  15. #include <chrono>
  16. #include <iostream>
  17. #include <nlohmann/json.hpp>
  18. #include <string>
  19. #include <thread>
  20. #include <unistd.h>
  21. using namespace smartbotic::database;
  22. namespace {
  23. // Shared pass/fail counters. New assertions use check() rather than assert()
  24. // so a failure names what it wanted and the binary keeps running to report
  25. // everything, instead of aborting on the first one. (The older tests in this
  26. // file still use assert(); tests/CMakeLists.txt applies -UNDEBUG so those are
  27. // live in every build type - see the v2.4.3 note there.)
  28. int g_pass = 0;
  29. int g_fail = 0;
  30. void check(bool cond, const char* msg) {
  31. if (cond) { ++g_pass; }
  32. else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
  33. }
  34. MemoryStore::Config evictionTestConfig(uint64_t maxMb = 1, uint32_t chunkSize = 100) {
  35. MemoryStore::Config cfg;
  36. cfg.nodeId = "test";
  37. cfg.maxMemoryBytes = maxMb * 1024 * 1024;
  38. cfg.evictionChunkSize = chunkSize;
  39. cfg.evictionChunkPauseMs = 1; // fast for tests
  40. cfg.evictionCheckIntervalMs = 50; // wake often
  41. cfg.hotWriteFloorMs = 100; // short floor for tests
  42. cfg.memorySoftPercent = 60;
  43. cfg.memoryHardPercent = 80;
  44. cfg.memoryEmergencyPercent = 95;
  45. cfg.evictionTargetPercent = 50;
  46. return cfg;
  47. }
  48. Document makeDoc(const std::string& id, size_t bytes = 1024) {
  49. Document d;
  50. d.id = id;
  51. std::string pad(bytes, 'x');
  52. d.set_data(nlohmann::json{{"value", pad}});
  53. return d;
  54. }
  55. void fillStore(MemoryStore& store, const std::string& collection, int n, size_t docBytes = 1024) {
  56. for (int i = 0; i < n; ++i) {
  57. store.insert(collection, makeDoc("d" + std::to_string(i), docBytes));
  58. }
  59. }
  60. } // anonymous namespace
  61. void test_pressure_levels() {
  62. auto cfg = evictionTestConfig(1); // 1 MB cap
  63. cfg.memorySoftPercent = 50;
  64. cfg.memoryHardPercent = 75;
  65. cfg.memoryEmergencyPercent = 90;
  66. MemoryStore store(cfg);
  67. store.start();
  68. store.createCollection("docs", CollectionOptions{});
  69. assert(store.pressure() == MemoryPressure::Normal);
  70. // Fill to trigger some pressure. Exact bytes/doc depend on overhead.
  71. fillStore(store, "docs", 400, 1024);
  72. // May be Normal or Soft depending on exact sizes
  73. auto p = store.pressure();
  74. // Just assert pressure() returns a valid enum value and doesn't crash.
  75. assert(p == MemoryPressure::Normal || p == MemoryPressure::Soft ||
  76. p == MemoryPressure::Hard || p == MemoryPressure::Emergency);
  77. store.stop();
  78. std::cout << "PASS: pressure level reported correctly as memory grows (level="
  79. << memoryPressureToString(p) << ")\n";
  80. }
  81. void test_hot_write_floor_protects_recent_writes() {
  82. auto cfg = evictionTestConfig(1);
  83. cfg.hotWriteFloorMs = 60000; // very high — nothing in test is "old"
  84. cfg.evictionCheckIntervalMs = 20;
  85. cfg.memorySoftPercent = 10; // force eviction immediately
  86. cfg.memoryHardPercent = 20;
  87. MemoryStore store(cfg);
  88. store.start();
  89. store.createCollection("docs", CollectionOptions{});
  90. // Fill above threshold — eviction should trigger
  91. fillStore(store, "docs", 500, 2048);
  92. // Wait for the eviction loop to tick at least twice
  93. std::this_thread::sleep_for(std::chrono::milliseconds(200));
  94. // With hot-write floor 60s, all recently-inserted docs should be
  95. // protected and live count should NOT drop significantly
  96. auto stats = store.getMemoryStatsSnapshot();
  97. uint64_t liveDocs = 0;
  98. for (const auto& c : stats.collections) liveDocs += c.documentCount;
  99. // Allow SOME eviction (could be non-hot-write edge case), but not most
  100. assert(liveDocs >= 450); // at least 90% preserved by hot-write floor
  101. store.stop();
  102. std::cout << "PASS: hot-write floor protects recently-written docs ("
  103. << liveDocs << "/500 preserved)\n";
  104. }
  105. // Priority bias: with two equally-sized collections, eviction must take from
  106. // the Low-priority one before the High-priority one.
  107. //
  108. // This test USED to call store.start() before filling, then sleep 500ms and
  109. // compare survivor counts. That raced its own fill loop against the 20ms
  110. // eviction tick, and the outcome depended on how fast inserts happened to be:
  111. //
  112. // - Optimised build: all 800 inserts land inside the first 20ms tick, so
  113. // eviction sees both collections at 400 and the bias assertion holds.
  114. // - Unoptimised build (a plain `cmake -B build -G Ninja`, i.e. no
  115. // CMAKE_BUILD_TYPE, which is what CLAUDE.md documents): only ~290 inserts
  116. // land in 20ms, so the first tick fires while "important" is still filling
  117. // and "archive" is empty or absent. Eviction then evicts only from
  118. // "important", latches the v2.4.3 50%-per-episode drain cap against that
  119. // partial resident set, and stays PAUSED for the rest of the episode.
  120. // "archive" is filled afterwards and never evicted at all, so
  121. // archiveLive (400) > importantLive — a guaranteed failure that says
  122. // nothing about the priority logic.
  123. //
  124. // Measured 10 failures in 12 runs unoptimised, 0 in 12 optimised, at the
  125. // relations merge-base as well as at branch HEAD. The product was never wrong;
  126. // the test's precondition was unstated.
  127. //
  128. // So the precondition is now established BEFORE the eviction thread exists:
  129. // fill both collections with the store stopped, assert the resident set is
  130. // exactly what the comparison assumes, and only then start eviction. The
  131. // settle wait polls until the live count stops moving instead of trusting a
  132. // fixed 500ms, and a timeout fails loudly rather than asserting on a
  133. // half-evicted store.
  134. void test_priority_low_evicted_first() {
  135. // 4 MB cap, target 25%: the fill lands well above the hard threshold so
  136. // eviction has real work, and there are survivors left to compare.
  137. auto cfg = evictionTestConfig(4);
  138. cfg.hotWriteFloorMs = 0; // disable hot-write protection
  139. cfg.evictionCheckIntervalMs = 20;
  140. cfg.memorySoftPercent = 30; // early trickle
  141. cfg.memoryHardPercent = 50; // hit hard with our fill
  142. cfg.memoryEmergencyPercent = 90;
  143. cfg.evictionTargetPercent = 25; // clear down to 25% - leaves headroom
  144. cfg.evictionChunkSize = 50; // small chunks, biased selection has effect
  145. cfg.maxEvictionPassesPerTrigger = 10;
  146. MemoryStore store(cfg);
  147. // Two collections: one HIGH priority, one LOW
  148. CollectionOptions highOpts;
  149. highOpts.memoryPriority = MemoryPriority::High;
  150. store.createCollection("important", highOpts);
  151. CollectionOptions lowOpts;
  152. lowOpts.memoryPriority = MemoryPriority::Low;
  153. store.createCollection("archive", lowOpts);
  154. // NOTE: store.start() has deliberately NOT been called yet. Nothing in
  155. // insert() needs the eviction or expiration thread, so the fill below runs
  156. // with no concurrent evictor and the resident set is fully determined.
  157. const uint64_t kPerCollection = 400;
  158. fillStore(store, "important", static_cast<int>(kPerCollection), 2048);
  159. fillStore(store, "archive", static_cast<int>(kPerCollection), 2048);
  160. auto liveCounts = [&]() {
  161. uint64_t important = 0, archive = 0;
  162. for (const auto& c : store.getMemoryStatsSnapshot().collections) {
  163. if (c.collection == "important") important = c.documentCount;
  164. if (c.collection == "archive") archive = c.documentCount;
  165. }
  166. return std::pair<uint64_t, uint64_t>{important, archive};
  167. };
  168. // Precondition, asserted rather than assumed: both collections are whole,
  169. // and the store is over the hard threshold so eviction is guaranteed to
  170. // run. If a future change makes the fill cheap enough not to trip
  171. // pressure, this fails loudly instead of the test passing vacuously.
  172. {
  173. auto [important0, archive0] = liveCounts();
  174. check(important0 == kPerCollection,
  175. "precondition: 'important' fully resident before eviction starts");
  176. check(archive0 == kPerCollection,
  177. "precondition: 'archive' fully resident before eviction starts");
  178. const MemoryPressure p0 = store.pressure();
  179. check(p0 == MemoryPressure::Hard || p0 == MemoryPressure::Emergency,
  180. "precondition: fill must put the store under hard/emergency pressure");
  181. }
  182. store.start();
  183. // Settle: poll until the live count has stopped moving. Eviction pauses
  184. // itself once it hits the drain cap or reaches the target, so this
  185. // converges quickly; the deadline exists only so a hang reports rather
  186. // than comparing a half-evicted store.
  187. const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
  188. uint64_t lastTotal = kPerCollection * 2;
  189. int stableTicks = 0;
  190. bool evictionRan = false;
  191. bool settled = false;
  192. while (std::chrono::steady_clock::now() < deadline) {
  193. std::this_thread::sleep_for(std::chrono::milliseconds(25));
  194. auto [important, archive] = liveCounts();
  195. const uint64_t total = important + archive;
  196. if (total < kPerCollection * 2) evictionRan = true;
  197. if (evictionRan && total == lastTotal) {
  198. if (++stableTicks >= 8) { settled = true; break; } // ~200ms of no change
  199. } else {
  200. stableTicks = 0;
  201. }
  202. lastTotal = total;
  203. }
  204. check(evictionRan, "eviction must run at all given the fill exceeds the cap");
  205. check(settled, "eviction must settle within the deadline");
  206. auto [importantLive, archiveLive] = liveCounts();
  207. // LOW priority should be evicted at least as much as HIGH.
  208. check(archiveLive <= importantLive,
  209. "low-priority collection must not outlive the high-priority one");
  210. // Some eviction MUST have happened given our fill vs cap.
  211. check(importantLive < kPerCollection || archiveLive < kPerCollection,
  212. "some eviction must have happened");
  213. // Expect strict bias once any eviction has run.
  214. check(archiveLive < importantLive,
  215. "low-priority collection must be evicted strictly more than high");
  216. store.stop();
  217. std::cout << "PASS: priority=Low collection evicted more than priority=High "
  218. << "(archive=" << archiveLive << ", important=" << importantLive << ")\n";
  219. }
  220. void test_eviction_chunk_size_honored() {
  221. auto cfg = evictionTestConfig(1);
  222. cfg.evictionChunkSize = 50; // small chunk
  223. cfg.maxEvictionPassesPerTrigger = 1; // exactly one chunk per tick
  224. cfg.hotWriteFloorMs = 0; // disable
  225. cfg.evictionCheckIntervalMs = 100;
  226. cfg.memorySoftPercent = 10;
  227. cfg.memoryHardPercent = 20;
  228. MemoryStore store(cfg);
  229. store.start();
  230. store.createCollection("docs", CollectionOptions{});
  231. fillStore(store, "docs", 500, 2048);
  232. // After one eviction tick, at most chunkSize docs should have been evicted
  233. auto before = store.getMemoryStatsSnapshot();
  234. uint64_t beforeLive = 0;
  235. for (const auto& c : before.collections) beforeLive += c.documentCount;
  236. std::this_thread::sleep_for(std::chrono::milliseconds(120)); // ~1 tick
  237. auto after = store.getMemoryStatsSnapshot();
  238. uint64_t afterLive = 0;
  239. for (const auto& c : after.collections) afterLive += c.documentCount;
  240. uint64_t evicted = (beforeLive > afterLive) ? (beforeLive - afterLive) : 0;
  241. // Should evict approximately chunkSize per tick, allow some slack.
  242. // 0 is possible if timing went weird, but > chunkSize*3 is definitely wrong.
  243. assert(evicted <= cfg.evictionChunkSize * 3);
  244. store.stop();
  245. std::cout << "PASS: eviction honors chunk size (evicted " << evicted
  246. << " docs in ~1 tick, chunkSize=" << cfg.evictionChunkSize << ")\n";
  247. }
  248. // v2.4.3 — eviction must never drain the store.
  249. //
  250. // Regression for the production incident: the memory estimate stopped
  251. // tracking what eviction could free, so `current <= targetBytes` was never
  252. // satisfied and the loop trimmed one chunk per tick for 30 minutes until 5996
  253. // of ~5990 docs were gone. Reads then fell back to an empty MemoryStore and
  254. // every collection reported 0 docs, which looked exactly like data loss.
  255. //
  256. // Here the target is made unreachable on purpose (evictionTargetPercent = 0,
  257. // so targetBytes = 0 and no amount of eviction satisfies it). Before the drain
  258. // cap this emptied the store; now it must stop at the cap.
  259. void test_eviction_never_drains_the_store() {
  260. auto cfg = evictionTestConfig(1);
  261. cfg.evictionTargetPercent = 0; // unreachable target
  262. cfg.memorySoftPercent = 1; // stay under pressure permanently
  263. cfg.memoryHardPercent = 2;
  264. cfg.memoryEmergencyPercent = 99; // don't trip admission control
  265. cfg.hotWriteFloorMs = 0; // everything is evictable
  266. cfg.evictionChunkSize = 50;
  267. cfg.evictionCheckIntervalMs = 20;
  268. cfg.evictionMaxEpisodePercent = 50; // cap under test
  269. MemoryStore store(cfg);
  270. store.start();
  271. store.createCollection("docs", CollectionOptions{});
  272. const uint64_t seeded = 400;
  273. fillStore(store, "docs", seeded, 2048);
  274. auto liveDocs = [&] {
  275. uint64_t n = 0;
  276. for (const auto& c : store.getMemoryStatsSnapshot().collections) {
  277. n += c.documentCount;
  278. }
  279. return n;
  280. };
  281. const uint64_t before = liveDocs();
  282. assert(before > 0);
  283. // Give the eviction thread many ticks — far more than it would need to
  284. // empty the store one 50-doc chunk at a time.
  285. std::this_thread::sleep_for(std::chrono::milliseconds(1200));
  286. const uint64_t after = liveDocs();
  287. const uint64_t evicted = (before > after) ? (before - after) : 0;
  288. // The cap is 50% of the episode's starting set. Allow one chunk of
  289. // overshoot, since the check runs per tick rather than per doc.
  290. const uint64_t cap = before / 2 + cfg.evictionChunkSize;
  291. assert(evicted <= cap);
  292. assert(after > 0); // the store must never be drained
  293. store.stop();
  294. std::cout << "PASS: eviction drain cap held (" << evicted << " of " << before
  295. << " evicted, " << after << " docs still resident)\n";
  296. }
  297. int main() {
  298. test_pressure_levels();
  299. test_hot_write_floor_protects_recent_writes();
  300. test_priority_low_evicted_first();
  301. test_eviction_chunk_size_honored();
  302. test_eviction_never_drains_the_store();
  303. if (g_fail != 0) {
  304. std::cerr << "\n" << g_fail << " check(s) FAILED (" << g_pass << " passed)\n";
  305. return 1;
  306. }
  307. std::cout << "\nAll eviction tests PASSED! (" << g_pass << " checks)\n";
  308. return 0;
  309. }