test_snapshot_durability.cpp 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. /**
  2. * Integration tests for v1.6.1 snapshot durability + fallback.
  3. *
  4. * Exercises SnapshotManager directly (no gRPC server).
  5. *
  6. * Test cases:
  7. * 1. atomic write leaves no .tmp file behind
  8. * 2. snapshot round-trip (write then load back)
  9. * 3. post-write verification catches externally-truncated file
  10. * 4. loadWithFallback uses older snapshot when newest is corrupt
  11. * 5. orphaned .tmp files cleaned by listSnapshots
  12. * 6. loadWithFallback throws when all snapshots corrupt
  13. */
  14. #include "../service/src/persistence/snapshot.hpp"
  15. #include "../service/src/persistence/wal.hpp"
  16. #include "../service/src/memory_store.hpp"
  17. #include "../service/src/document.hpp"
  18. #include <cassert>
  19. #include <chrono>
  20. #include <cstdint>
  21. #include <filesystem>
  22. #include <fstream>
  23. #include <iostream>
  24. #include <nlohmann/json.hpp>
  25. #include <thread>
  26. #include <unistd.h> // getpid
  27. #include <unordered_set>
  28. namespace fs = std::filesystem;
  29. using smartbotic::database::MemoryStore;
  30. using smartbotic::database::SnapshotManager;
  31. using smartbotic::database::Document;
  32. using smartbotic::database::CollectionOptions;
  33. namespace {
  34. MemoryStore::Config defaultStoreConfig() {
  35. MemoryStore::Config cfg;
  36. cfg.nodeId = "test";
  37. cfg.maxMemoryBytes = 256ULL * 1024 * 1024;
  38. return cfg;
  39. }
  40. fs::path makeSnapshotDir(const std::string& name) {
  41. auto dir = fs::temp_directory_path() /
  42. ("snap-durability-" + name + "-" + std::to_string(::getpid()));
  43. fs::remove_all(dir);
  44. fs::create_directories(dir);
  45. return dir;
  46. }
  47. Document makeDoc(const std::string& id) {
  48. Document d;
  49. d.id = id;
  50. d.set_data(nlohmann::json{{"value", id}});
  51. return d;
  52. }
  53. } // anonymous namespace
  54. // ──────────────────────────────────────────────────────────
  55. // Test 1: atomic write leaves no .tmp file behind
  56. // ──────────────────────────────────────────────────────────
  57. void test_atomic_write_leaves_no_tmp() {
  58. auto dir = makeSnapshotDir("atomic");
  59. SnapshotManager::Config scfg;
  60. scfg.snapshotDir = dir;
  61. scfg.validateAfterWrite = true;
  62. SnapshotManager mgr(scfg);
  63. MemoryStore store(defaultStoreConfig());
  64. store.start();
  65. store.createCollection("docs", CollectionOptions{});
  66. for (int i = 0; i < 50; i++) {
  67. store.insert("docs", makeDoc("d" + std::to_string(i)));
  68. }
  69. auto path = mgr.createSnapshot(store, 42);
  70. // No .tmp files should remain
  71. int tmpCount = 0;
  72. for (auto& e : fs::directory_iterator(dir)) {
  73. if (e.path().extension() == ".tmp") tmpCount++;
  74. }
  75. assert(tmpCount == 0);
  76. // The final file should exist and pass verification
  77. assert(fs::exists(path));
  78. assert(mgr.verifySnapshot(path));
  79. store.stop();
  80. fs::remove_all(dir);
  81. std::cout << "PASS: atomic write leaves no .tmp file behind\n";
  82. }
  83. // ──────────────────────────────────────────────────────────
  84. // Test 2: snapshot round-trips (write then load back)
  85. // ──────────────────────────────────────────────────────────
  86. void test_snapshot_round_trip() {
  87. auto dir = makeSnapshotDir("roundtrip");
  88. SnapshotManager::Config scfg;
  89. scfg.snapshotDir = dir;
  90. SnapshotManager mgr(scfg);
  91. MemoryStore store(defaultStoreConfig());
  92. store.start();
  93. store.createCollection("docs", CollectionOptions{});
  94. for (int i = 0; i < 20; i++) {
  95. store.insert("docs", makeDoc("d" + std::to_string(i)));
  96. }
  97. auto path = mgr.createSnapshot(store, 99);
  98. store.stop();
  99. MemoryStore store2(defaultStoreConfig());
  100. store2.start();
  101. uint64_t seq = mgr.loadSnapshot(path, store2);
  102. assert(seq == 99);
  103. auto doc = store2.get("docs", "d5");
  104. assert(doc.has_value());
  105. store2.stop();
  106. fs::remove_all(dir);
  107. std::cout << "PASS: snapshot round-trip\n";
  108. }
  109. // ──────────────────────────────────────────────────────────
  110. // Test 3: post-write verification catches externally-truncated file
  111. // ──────────────────────────────────────────────────────────
  112. void test_verification_catches_truncation() {
  113. auto dir = makeSnapshotDir("truncate");
  114. SnapshotManager::Config scfg;
  115. scfg.snapshotDir = dir;
  116. SnapshotManager mgr(scfg);
  117. MemoryStore store(defaultStoreConfig());
  118. store.start();
  119. store.createCollection("docs", CollectionOptions{});
  120. for (int i = 0; i < 50; i++) {
  121. store.insert("docs", makeDoc("d" + std::to_string(i)));
  122. }
  123. auto path = mgr.createSnapshot(store, 1);
  124. store.stop();
  125. // Truncate the file AFTER a successful write to simulate the Zoe scenario
  126. uintmax_t original = fs::file_size(path);
  127. fs::resize_file(path, original / 2);
  128. // verifySnapshot should return false
  129. assert(!mgr.verifySnapshot(path));
  130. fs::remove_all(dir);
  131. std::cout << "PASS: post-write verification catches truncation\n";
  132. }
  133. // ──────────────────────────────────────────────────────────
  134. // Test 4: loadWithFallback uses older snapshot when newest is corrupt
  135. // ──────────────────────────────────────────────────────────
  136. void test_loadWithFallback_uses_older_when_newest_corrupt() {
  137. auto dir = makeSnapshotDir("fallback");
  138. SnapshotManager::Config scfg;
  139. scfg.snapshotDir = dir;
  140. scfg.maxSnapshots = 10;
  141. SnapshotManager mgr(scfg);
  142. MemoryStore store(defaultStoreConfig());
  143. store.start();
  144. store.createCollection("docs", CollectionOptions{});
  145. // Older snapshot — 10 docs
  146. for (int i = 0; i < 10; i++) {
  147. store.insert("docs", makeDoc("d" + std::to_string(i)));
  148. }
  149. auto older = mgr.createSnapshot(store, 100);
  150. // Filenames include timestamps down to the second, so sleep to ensure ordering
  151. std::this_thread::sleep_for(std::chrono::seconds(1));
  152. // Newer snapshot — 20 docs
  153. for (int i = 10; i < 20; i++) {
  154. store.insert("docs", makeDoc("d" + std::to_string(i)));
  155. }
  156. auto newer = mgr.createSnapshot(store, 200);
  157. store.stop();
  158. assert(older != newer);
  159. // Corrupt the newer
  160. fs::resize_file(newer, fs::file_size(newer) / 2);
  161. assert(!mgr.verifySnapshot(newer));
  162. // loadWithFallback should transparently fall back to the older
  163. MemoryStore store2(defaultStoreConfig());
  164. store2.start();
  165. fs::path used;
  166. size_t attempted = 0;
  167. uint64_t seq = mgr.loadWithFallback(store2, &used, &attempted);
  168. assert(seq == 100);
  169. assert(used == older);
  170. assert(attempted == 2); // tried newer first, then older
  171. store2.stop();
  172. fs::remove_all(dir);
  173. std::cout << "PASS: loadWithFallback uses older snapshot when newest corrupt\n";
  174. }
  175. // ──────────────────────────────────────────────────────────
  176. // Test 5: orphaned .tmp files cleaned by listSnapshots()
  177. // ──────────────────────────────────────────────────────────
  178. void test_orphaned_tmp_cleaned_by_listSnapshots() {
  179. auto dir = makeSnapshotDir("orphan");
  180. SnapshotManager::Config scfg;
  181. scfg.snapshotDir = dir;
  182. SnapshotManager mgr(scfg);
  183. // Manually plant a fake .tmp like a crashed-mid-write would leave
  184. auto tmp = dir / "snapshot-20260101-120000.dat.tmp";
  185. {
  186. std::ofstream f(tmp);
  187. f << "partial";
  188. }
  189. assert(fs::exists(tmp));
  190. // listSnapshots should clean up the .tmp on its pre-scan pass
  191. (void)mgr.listSnapshots();
  192. assert(!fs::exists(tmp));
  193. fs::remove_all(dir);
  194. std::cout << "PASS: orphaned .tmp cleaned by listSnapshots\n";
  195. }
  196. // ──────────────────────────────────────────────────────────
  197. // Test 6: loadWithFallback throws when all snapshots corrupt
  198. // ──────────────────────────────────────────────────────────
  199. void test_loadWithFallback_throws_when_all_corrupt() {
  200. auto dir = makeSnapshotDir("allcorrupt");
  201. SnapshotManager::Config scfg;
  202. scfg.snapshotDir = dir;
  203. scfg.maxSnapshots = 10;
  204. SnapshotManager mgr(scfg);
  205. MemoryStore store(defaultStoreConfig());
  206. store.start();
  207. store.createCollection("docs", CollectionOptions{});
  208. store.insert("docs", makeDoc("d1"));
  209. auto s1 = mgr.createSnapshot(store, 1);
  210. std::this_thread::sleep_for(std::chrono::seconds(1));
  211. store.insert("docs", makeDoc("d2"));
  212. auto s2 = mgr.createSnapshot(store, 2);
  213. store.stop();
  214. // Corrupt both
  215. fs::resize_file(s1, fs::file_size(s1) / 2);
  216. fs::resize_file(s2, fs::file_size(s2) / 2);
  217. MemoryStore store2(defaultStoreConfig());
  218. store2.start();
  219. bool threw = false;
  220. try {
  221. mgr.loadWithFallback(store2);
  222. } catch (const std::exception& e) {
  223. threw = true;
  224. std::string msg = e.what();
  225. assert(msg.find("all") != std::string::npos);
  226. }
  227. assert(threw);
  228. store2.stop();
  229. fs::remove_all(dir);
  230. std::cout << "PASS: loadWithFallback throws when all snapshots corrupt\n";
  231. }
  232. // ──────────────────────────────────────────────────────────
  233. // Test 7: v4 chunked format round-trips at scale (multi-chunk path)
  234. // ──────────────────────────────────────────────────────────
  235. void test_v4_format_roundtrip_at_scale() {
  236. auto dir = makeSnapshotDir("v4scale");
  237. SnapshotManager::Config scfg;
  238. scfg.snapshotDir = dir;
  239. scfg.compressionEnabled = true;
  240. SnapshotManager mgr(scfg);
  241. MemoryStore::Config storeCfg = defaultStoreConfig();
  242. storeCfg.maxMemoryBytes = 512ULL * 1024 * 1024; // room for 10K x ~2 KB docs
  243. MemoryStore store(storeCfg);
  244. store.start();
  245. store.createCollection("docs", CollectionOptions{});
  246. // Make each doc ~2 KB so 10,000 docs > 16 MB, forcing multi-chunk body.
  247. std::string bigString(2000, 'x');
  248. for (int i = 0; i < 10000; i++) {
  249. Document d;
  250. d.id = "d" + std::to_string(i);
  251. d.set_data(nlohmann::json{{"value", bigString}, {"i", i}});
  252. store.insert("docs", d);
  253. }
  254. auto path = mgr.createSnapshot(store, 1);
  255. store.stop();
  256. MemoryStore store2(storeCfg);
  257. store2.start();
  258. uint64_t seq = mgr.loadSnapshot(path, store2);
  259. assert(seq == 1);
  260. auto d = store2.get("docs", "d9999");
  261. assert(d.has_value());
  262. assert((*d).data()["i"].get<int>() == 9999);
  263. store2.stop();
  264. fs::remove_all(dir);
  265. std::cout << "PASS: v4 chunked format round-trips at scale (10K docs, 20MB+)\n";
  266. }
  267. // ──────────────────────────────────────────────────────────
  268. // Test 8: WAL sequence anchoring (regression for v1.7.2)
  269. //
  270. // After a snapshot at sequence S, truncateBefore(S) deletes every WAL
  271. // file (because all entries have sequence ≤ S). The next boot's
  272. // wal_->open() finds no files, so sequence_ stays at 0. Subsequent
  273. // appends would produce 1, 2, 3, … which look "older than the snapshot"
  274. // to the boot AFTER that and get silently dropped by recovery's
  275. // `replay(snapSeq)` filter.
  276. //
  277. // PersistenceManager::replayWal now calls wal_->setSequenceFloor(snapSeq)
  278. // to anchor the WAL counter to the snapshot's walSequence — this test
  279. // exercises that anchoring directly on the WAL.
  280. //
  281. // Bug fingerprint on Zoe (2026-05-07): messages from 10:55–11:41 lost
  282. // across the 11:42 restart; same migration applied twice across the
  283. // two boots because the migrations-applied marker was written with
  284. // sequence 1 and discarded by the next recovery.
  285. // ──────────────────────────────────────────────────────────
  286. void test_wal_sequence_floor_anchors_after_truncate() {
  287. using smartbotic::database::WriteAheadLog;
  288. auto dir = makeSnapshotDir("wal-floor");
  289. WriteAheadLog::Config cfg;
  290. cfg.walDir = dir;
  291. // Phase 1 — fresh WAL, append 5 entries, snapshot at seq=5,
  292. // truncate everything ≤ 5. Mirrors the steady-state cycle.
  293. {
  294. WriteAheadLog wal(cfg);
  295. assert(wal.open());
  296. for (int i = 0; i < 5; ++i) {
  297. auto entry = WriteAheadLog::makeInsertEntry(
  298. "test", makeDoc("d" + std::to_string(i)), "test-node");
  299. uint64_t seq = wal.append(std::move(entry));
  300. assert(seq == static_cast<uint64_t>(i + 1));
  301. }
  302. assert(wal.currentSequence() == 5);
  303. wal.truncateBefore(5);
  304. wal.close();
  305. }
  306. // After truncate the on-disk WAL files are deleted. The next boot
  307. // simulates the post-snapshot startup.
  308. {
  309. WriteAheadLog wal(cfg);
  310. assert(wal.open());
  311. // Without anchoring, sequence_ would be 0 here — that's the bug.
  312. // The recovery path is responsible for raising the floor; the
  313. // WAL itself has no way to know about the snapshot.
  314. assert(wal.currentSequence() == 0);
  315. // Now anchor to the snapshot's walSequence (5).
  316. wal.setSequenceFloor(5);
  317. assert(wal.currentSequence() == 5);
  318. // setSequenceFloor must be monotonic — calling with a lower
  319. // value is a no-op.
  320. wal.setSequenceFloor(3);
  321. assert(wal.currentSequence() == 5);
  322. // Subsequent appends produce 6, 7, … — strictly greater than
  323. // the snapshot's walSequence, so the next boot's recovery
  324. // (which calls `replay(snapSeq)` with snapSeq=5) will see them.
  325. auto entry = WriteAheadLog::makeInsertEntry(
  326. "test", makeDoc("post"), "test-node");
  327. uint64_t seq = wal.append(std::move(entry));
  328. assert(seq == 6);
  329. wal.close();
  330. }
  331. // Phase 2 — re-open one more time, verify sequence_ recovers from
  332. // the new on-disk file at 6 (open() reads the highest entry).
  333. {
  334. WriteAheadLog wal(cfg);
  335. assert(wal.open());
  336. assert(wal.currentSequence() >= 6);
  337. wal.close();
  338. }
  339. fs::remove_all(dir);
  340. std::cout << "PASS: WAL sequence floor anchors after snapshot truncate\n";
  341. }
  342. // ──────────────────────────────────────────────────────────
  343. // Test: snapshot must include evicted documents (v1.7.3 fix)
  344. // ──────────────────────────────────────────────────────────
  345. //
  346. // Pre-v1.7.3: serializeStore() walked only `coll->documents` (in-memory),
  347. // silently omitting docs that had been evicted to stubs. Each snapshot
  348. // rotation truncated the WAL past the snapshot's sequence, deleting the
  349. // only remaining copy of those evicted docs — they were lost forever
  350. // across the next restart.
  351. void test_snapshot_includes_evicted_documents() {
  352. using smartbotic::database::WriteAheadLog;
  353. auto snapDir = makeSnapshotDir("evict-snap");
  354. auto walDir = makeSnapshotDir("evict-wal");
  355. WriteAheadLog::Config walCfg;
  356. walCfg.walDir = walDir;
  357. WriteAheadLog wal(walCfg);
  358. if (!wal.open()) {
  359. std::cerr << "FAIL: wal.open() returned false\n";
  360. std::exit(2);
  361. }
  362. // Tiny memory limit so eviction fires under load.
  363. MemoryStore::Config storeCfg;
  364. storeCfg.nodeId = "test";
  365. storeCfg.maxMemoryBytes = 32ULL * 1024;
  366. storeCfg.evictionThresholdPercent = 50;
  367. storeCfg.evictionTargetPercent = 30;
  368. storeCfg.hotWriteFloorMs = 0; // disable hot-write protection
  369. storeCfg.evictionChunkSize = 1000;
  370. MemoryStore store(storeCfg);
  371. store.start();
  372. store.createCollection("docs", CollectionOptions{});
  373. // Wire load callback: scan WAL for the requested doc, same path
  374. // production uses (database_service.cpp installs a similar lambda
  375. // backed by PersistenceManager::loadDocument).
  376. store.setDocumentLoadCallback(
  377. [&wal](const std::string& coll, const std::string& id,
  378. uint64_t /*walSeq*/) -> std::optional<Document> {
  379. std::optional<Document> found;
  380. wal.replay(0, [&](const smartbotic::database::WalEntry& e) {
  381. if (e.collection == coll && e.documentId == id && e.data.has_value()) {
  382. Document d = Document::fromJson(*e.data);
  383. d.id = id;
  384. d.collection = coll;
  385. found = d;
  386. }
  387. });
  388. return found;
  389. });
  390. // Insert N docs, mirroring to WAL so the load callback can resolve them.
  391. constexpr int N = 100;
  392. for (int i = 0; i < N; ++i) {
  393. Document d = makeDoc("doc-" + std::to_string(i));
  394. {
  395. auto tree = d.data();
  396. tree["padding"] = std::string(300, 'x'); // make eviction meaningful
  397. d.set_data(tree);
  398. }
  399. wal.append(WriteAheadLog::makeInsertEntry("docs", d, "test"));
  400. store.insert("docs", d);
  401. }
  402. // Force eviction.
  403. store.evictDocuments();
  404. auto evictedIds = store.getEvictedDocumentIds("docs");
  405. auto inMemoryDocs = store.getAllDocuments("docs");
  406. assert(!evictedIds.empty());
  407. assert(evictedIds.size() + inMemoryDocs.size() == static_cast<size_t>(N));
  408. // Flush WAL so the load callback (which reads via ifstream) sees every
  409. // entry. In production the WAL is synced periodically by the persistence
  410. // manager; tests need to force it.
  411. wal.sync();
  412. // Take snapshot.
  413. SnapshotManager::Config scfg;
  414. scfg.snapshotDir = snapDir;
  415. SnapshotManager mgr(scfg);
  416. auto path = mgr.createSnapshot(store, wal.currentSequence());
  417. // Load into fresh store with a huge memory budget so eviction doesn't
  418. // fire during verification — we want to see every doc in-memory.
  419. MemoryStore::Config newCfg = storeCfg;
  420. newCfg.maxMemoryBytes = 256ULL * 1024 * 1024;
  421. MemoryStore newStore(newCfg);
  422. newStore.start();
  423. mgr.loadSnapshot(path, newStore);
  424. auto loadedDocs = newStore.getAllDocuments("docs");
  425. auto loadedEvicted = newStore.getEvictedDocumentIds("docs");
  426. const size_t totalLoaded = loadedDocs.size() + loadedEvicted.size();
  427. // Real check (assert is a no-op in Release builds with NDEBUG).
  428. if (totalLoaded != static_cast<size_t>(N)) {
  429. std::cerr << "FAIL: snapshot lost docs — loaded "
  430. << loadedDocs.size() << " in-memory + "
  431. << loadedEvicted.size() << " evicted = "
  432. << totalLoaded << " of " << N << " inserted\n";
  433. std::exit(1);
  434. }
  435. std::unordered_set<std::string> loadedIdSet;
  436. for (const auto& d : loadedDocs) loadedIdSet.insert(d.id);
  437. for (const auto& id : loadedEvicted) loadedIdSet.insert(id);
  438. for (const auto& id : evictedIds) {
  439. if (loadedIdSet.count(id) != 1) {
  440. std::cerr << "FAIL: evicted doc " << id
  441. << " missing from loaded snapshot\n";
  442. std::exit(1);
  443. }
  444. }
  445. wal.close();
  446. fs::remove_all(snapDir);
  447. fs::remove_all(walDir);
  448. std::cout << "PASS: snapshot includes evicted documents ("
  449. << inMemoryDocs.size() << " in-memory + "
  450. << evictedIds.size() << " evicted, all "
  451. << N << " round-tripped)\n";
  452. }
  453. // ──────────────────────────────────────────────────────────
  454. // Test: eviction stubs carry the real WAL sequence (v1.7.4 fix)
  455. // ──────────────────────────────────────────────────────────
  456. //
  457. // Pre-v1.7.4 the stub stored doc.version (1, 2, 3…) under the misleading
  458. // name `walSequence`. Production load callbacks therefore couldn't use
  459. // it as a `fromSequence` hint and fell back to scanning the entire WAL
  460. // from sequence 0 on every evicted-doc fault — O(WAL_size) per fault,
  461. // O(WAL_size × evicted_count) per eviction tick.
  462. void test_evict_stub_carries_real_wal_sequence() {
  463. using smartbotic::database::WriteAheadLog;
  464. auto walDir = makeSnapshotDir("stub-walseq");
  465. WriteAheadLog::Config walCfg;
  466. walCfg.walDir = walDir;
  467. WriteAheadLog wal(walCfg);
  468. if (!wal.open()) { std::cerr << "wal.open failed\n"; std::exit(2); }
  469. MemoryStore::Config storeCfg;
  470. storeCfg.nodeId = "test";
  471. storeCfg.maxMemoryBytes = 32ULL * 1024;
  472. storeCfg.evictionThresholdPercent = 50;
  473. storeCfg.evictionTargetPercent = 30;
  474. storeCfg.hotWriteFloorMs = 0;
  475. storeCfg.evictionChunkSize = 1000;
  476. MemoryStore store(storeCfg);
  477. store.start();
  478. store.createCollection("docs", CollectionOptions{});
  479. // Insert N docs. Mirror to WAL and record the actual sequence on the
  480. // store — this is what database_service.cpp's persist callback does.
  481. constexpr int N = 50;
  482. std::unordered_map<std::string, uint64_t> expectedSeq;
  483. for (int i = 0; i < N; ++i) {
  484. Document d = makeDoc("doc-" + std::to_string(i));
  485. {
  486. auto tree = d.data();
  487. tree["padding"] = std::string(300, 'x');
  488. d.set_data(tree);
  489. }
  490. uint64_t seq = wal.append(WriteAheadLog::makeInsertEntry("docs", d, "test"));
  491. store.insert("docs", d);
  492. store.recordWalSequence("docs", d.id, seq);
  493. expectedSeq[d.id] = seq;
  494. }
  495. // Re-write a few docs so their `walSequence` should match the LATEST
  496. // append, not the first one.
  497. for (int i = 0; i < 5; ++i) {
  498. Document d = makeDoc("doc-" + std::to_string(i));
  499. {
  500. auto tree = d.data();
  501. tree["padding"] = std::string(400, 'y');
  502. d.set_data(tree);
  503. }
  504. d.version = 2;
  505. uint64_t seq = wal.append(WriteAheadLog::makeUpdateEntry("docs", d, "test"));
  506. store.update("docs", d.id, d);
  507. store.recordWalSequence("docs", d.id, seq);
  508. expectedSeq[d.id] = seq;
  509. }
  510. store.evictDocuments();
  511. auto evictedIds = store.getEvictedDocumentIds("docs");
  512. if (evictedIds.empty()) {
  513. std::cerr << "FAIL: no docs evicted — eviction config too lax\n";
  514. std::exit(1);
  515. }
  516. // Every evicted stub must have the real (latest-append) seq, not 0
  517. // and not the per-doc version (1 or 2).
  518. for (const auto& id : evictedIds) {
  519. auto stub = store.getEvictedStub("docs", id);
  520. if (!stub) {
  521. std::cerr << "FAIL: no stub for " << id << "\n";
  522. std::exit(1);
  523. }
  524. uint64_t want = expectedSeq[id];
  525. if (stub->walSequence != want) {
  526. std::cerr << "FAIL: stub.walSequence=" << stub->walSequence
  527. << " for " << id << ", expected " << want
  528. << " (real WAL sequence)\n";
  529. std::exit(1);
  530. }
  531. // Sanity: must not equal a per-doc version counter (≤ 2 here).
  532. if (stub->walSequence <= 2) {
  533. std::cerr << "FAIL: stub.walSequence=" << stub->walSequence
  534. << " looks like a per-doc version, not a WAL seq\n";
  535. std::exit(1);
  536. }
  537. }
  538. wal.close();
  539. fs::remove_all(walDir);
  540. std::cout << "PASS: evict stubs carry real WAL sequence ("
  541. << evictedIds.size() << " stubs verified, all match "
  542. << "their last wal.append() return value)\n";
  543. }
  544. int main() {
  545. test_atomic_write_leaves_no_tmp();
  546. test_snapshot_round_trip();
  547. test_verification_catches_truncation();
  548. test_loadWithFallback_uses_older_when_newest_corrupt();
  549. test_orphaned_tmp_cleaned_by_listSnapshots();
  550. test_loadWithFallback_throws_when_all_corrupt();
  551. test_v4_format_roundtrip_at_scale();
  552. test_wal_sequence_floor_anchors_after_truncate();
  553. test_snapshot_includes_evicted_documents();
  554. test_evict_stub_carries_real_wal_sequence();
  555. std::cout << "\nAll snapshot durability tests PASSED!\n";
  556. return 0;
  557. }