test_snapshot_durability.cpp 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  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. namespace fs = std::filesystem;
  28. using smartbotic::database::MemoryStore;
  29. using smartbotic::database::SnapshotManager;
  30. using smartbotic::database::Document;
  31. using smartbotic::database::CollectionOptions;
  32. namespace {
  33. MemoryStore::Config defaultStoreConfig() {
  34. MemoryStore::Config cfg;
  35. cfg.nodeId = "test";
  36. cfg.maxMemoryBytes = 256ULL * 1024 * 1024;
  37. return cfg;
  38. }
  39. fs::path makeSnapshotDir(const std::string& name) {
  40. auto dir = fs::temp_directory_path() /
  41. ("snap-durability-" + name + "-" + std::to_string(::getpid()));
  42. fs::remove_all(dir);
  43. fs::create_directories(dir);
  44. return dir;
  45. }
  46. Document makeDoc(const std::string& id) {
  47. Document d;
  48. d.id = id;
  49. d.data = nlohmann::json{{"value", id}};
  50. return d;
  51. }
  52. } // anonymous namespace
  53. // ──────────────────────────────────────────────────────────
  54. // Test 1: atomic write leaves no .tmp file behind
  55. // ──────────────────────────────────────────────────────────
  56. void test_atomic_write_leaves_no_tmp() {
  57. auto dir = makeSnapshotDir("atomic");
  58. SnapshotManager::Config scfg;
  59. scfg.snapshotDir = dir;
  60. scfg.validateAfterWrite = true;
  61. SnapshotManager mgr(scfg);
  62. MemoryStore store(defaultStoreConfig());
  63. store.start();
  64. store.createCollection("docs", CollectionOptions{});
  65. for (int i = 0; i < 50; i++) {
  66. store.insert("docs", makeDoc("d" + std::to_string(i)));
  67. }
  68. auto path = mgr.createSnapshot(store, 42);
  69. // No .tmp files should remain
  70. int tmpCount = 0;
  71. for (auto& e : fs::directory_iterator(dir)) {
  72. if (e.path().extension() == ".tmp") tmpCount++;
  73. }
  74. assert(tmpCount == 0);
  75. // The final file should exist and pass verification
  76. assert(fs::exists(path));
  77. assert(mgr.verifySnapshot(path));
  78. store.stop();
  79. fs::remove_all(dir);
  80. std::cout << "PASS: atomic write leaves no .tmp file behind\n";
  81. }
  82. // ──────────────────────────────────────────────────────────
  83. // Test 2: snapshot round-trips (write then load back)
  84. // ──────────────────────────────────────────────────────────
  85. void test_snapshot_round_trip() {
  86. auto dir = makeSnapshotDir("roundtrip");
  87. SnapshotManager::Config scfg;
  88. scfg.snapshotDir = dir;
  89. SnapshotManager mgr(scfg);
  90. MemoryStore store(defaultStoreConfig());
  91. store.start();
  92. store.createCollection("docs", CollectionOptions{});
  93. for (int i = 0; i < 20; i++) {
  94. store.insert("docs", makeDoc("d" + std::to_string(i)));
  95. }
  96. auto path = mgr.createSnapshot(store, 99);
  97. store.stop();
  98. MemoryStore store2(defaultStoreConfig());
  99. store2.start();
  100. uint64_t seq = mgr.loadSnapshot(path, store2);
  101. assert(seq == 99);
  102. auto doc = store2.get("docs", "d5");
  103. assert(doc.has_value());
  104. store2.stop();
  105. fs::remove_all(dir);
  106. std::cout << "PASS: snapshot round-trip\n";
  107. }
  108. // ──────────────────────────────────────────────────────────
  109. // Test 3: post-write verification catches externally-truncated file
  110. // ──────────────────────────────────────────────────────────
  111. void test_verification_catches_truncation() {
  112. auto dir = makeSnapshotDir("truncate");
  113. SnapshotManager::Config scfg;
  114. scfg.snapshotDir = dir;
  115. SnapshotManager mgr(scfg);
  116. MemoryStore store(defaultStoreConfig());
  117. store.start();
  118. store.createCollection("docs", CollectionOptions{});
  119. for (int i = 0; i < 50; i++) {
  120. store.insert("docs", makeDoc("d" + std::to_string(i)));
  121. }
  122. auto path = mgr.createSnapshot(store, 1);
  123. store.stop();
  124. // Truncate the file AFTER a successful write to simulate the Zoe scenario
  125. uintmax_t original = fs::file_size(path);
  126. fs::resize_file(path, original / 2);
  127. // verifySnapshot should return false
  128. assert(!mgr.verifySnapshot(path));
  129. fs::remove_all(dir);
  130. std::cout << "PASS: post-write verification catches truncation\n";
  131. }
  132. // ──────────────────────────────────────────────────────────
  133. // Test 4: loadWithFallback uses older snapshot when newest is corrupt
  134. // ──────────────────────────────────────────────────────────
  135. void test_loadWithFallback_uses_older_when_newest_corrupt() {
  136. auto dir = makeSnapshotDir("fallback");
  137. SnapshotManager::Config scfg;
  138. scfg.snapshotDir = dir;
  139. scfg.maxSnapshots = 10;
  140. SnapshotManager mgr(scfg);
  141. MemoryStore store(defaultStoreConfig());
  142. store.start();
  143. store.createCollection("docs", CollectionOptions{});
  144. // Older snapshot — 10 docs
  145. for (int i = 0; i < 10; i++) {
  146. store.insert("docs", makeDoc("d" + std::to_string(i)));
  147. }
  148. auto older = mgr.createSnapshot(store, 100);
  149. // Filenames include timestamps down to the second, so sleep to ensure ordering
  150. std::this_thread::sleep_for(std::chrono::seconds(1));
  151. // Newer snapshot — 20 docs
  152. for (int i = 10; i < 20; i++) {
  153. store.insert("docs", makeDoc("d" + std::to_string(i)));
  154. }
  155. auto newer = mgr.createSnapshot(store, 200);
  156. store.stop();
  157. assert(older != newer);
  158. // Corrupt the newer
  159. fs::resize_file(newer, fs::file_size(newer) / 2);
  160. assert(!mgr.verifySnapshot(newer));
  161. // loadWithFallback should transparently fall back to the older
  162. MemoryStore store2(defaultStoreConfig());
  163. store2.start();
  164. fs::path used;
  165. size_t attempted = 0;
  166. uint64_t seq = mgr.loadWithFallback(store2, &used, &attempted);
  167. assert(seq == 100);
  168. assert(used == older);
  169. assert(attempted == 2); // tried newer first, then older
  170. store2.stop();
  171. fs::remove_all(dir);
  172. std::cout << "PASS: loadWithFallback uses older snapshot when newest corrupt\n";
  173. }
  174. // ──────────────────────────────────────────────────────────
  175. // Test 5: orphaned .tmp files cleaned by listSnapshots()
  176. // ──────────────────────────────────────────────────────────
  177. void test_orphaned_tmp_cleaned_by_listSnapshots() {
  178. auto dir = makeSnapshotDir("orphan");
  179. SnapshotManager::Config scfg;
  180. scfg.snapshotDir = dir;
  181. SnapshotManager mgr(scfg);
  182. // Manually plant a fake .tmp like a crashed-mid-write would leave
  183. auto tmp = dir / "snapshot-20260101-120000.dat.tmp";
  184. {
  185. std::ofstream f(tmp);
  186. f << "partial";
  187. }
  188. assert(fs::exists(tmp));
  189. // listSnapshots should clean up the .tmp on its pre-scan pass
  190. (void)mgr.listSnapshots();
  191. assert(!fs::exists(tmp));
  192. fs::remove_all(dir);
  193. std::cout << "PASS: orphaned .tmp cleaned by listSnapshots\n";
  194. }
  195. // ──────────────────────────────────────────────────────────
  196. // Test 6: loadWithFallback throws when all snapshots corrupt
  197. // ──────────────────────────────────────────────────────────
  198. void test_loadWithFallback_throws_when_all_corrupt() {
  199. auto dir = makeSnapshotDir("allcorrupt");
  200. SnapshotManager::Config scfg;
  201. scfg.snapshotDir = dir;
  202. scfg.maxSnapshots = 10;
  203. SnapshotManager mgr(scfg);
  204. MemoryStore store(defaultStoreConfig());
  205. store.start();
  206. store.createCollection("docs", CollectionOptions{});
  207. store.insert("docs", makeDoc("d1"));
  208. auto s1 = mgr.createSnapshot(store, 1);
  209. std::this_thread::sleep_for(std::chrono::seconds(1));
  210. store.insert("docs", makeDoc("d2"));
  211. auto s2 = mgr.createSnapshot(store, 2);
  212. store.stop();
  213. // Corrupt both
  214. fs::resize_file(s1, fs::file_size(s1) / 2);
  215. fs::resize_file(s2, fs::file_size(s2) / 2);
  216. MemoryStore store2(defaultStoreConfig());
  217. store2.start();
  218. bool threw = false;
  219. try {
  220. mgr.loadWithFallback(store2);
  221. } catch (const std::exception& e) {
  222. threw = true;
  223. std::string msg = e.what();
  224. assert(msg.find("all") != std::string::npos);
  225. }
  226. assert(threw);
  227. store2.stop();
  228. fs::remove_all(dir);
  229. std::cout << "PASS: loadWithFallback throws when all snapshots corrupt\n";
  230. }
  231. // ──────────────────────────────────────────────────────────
  232. // Test 7: v4 chunked format round-trips at scale (multi-chunk path)
  233. // ──────────────────────────────────────────────────────────
  234. void test_v4_format_roundtrip_at_scale() {
  235. auto dir = makeSnapshotDir("v4scale");
  236. SnapshotManager::Config scfg;
  237. scfg.snapshotDir = dir;
  238. scfg.compressionEnabled = true;
  239. SnapshotManager mgr(scfg);
  240. MemoryStore::Config storeCfg = defaultStoreConfig();
  241. storeCfg.maxMemoryBytes = 512ULL * 1024 * 1024; // room for 10K x ~2 KB docs
  242. MemoryStore store(storeCfg);
  243. store.start();
  244. store.createCollection("docs", CollectionOptions{});
  245. // Make each doc ~2 KB so 10,000 docs > 16 MB, forcing multi-chunk body.
  246. std::string bigString(2000, 'x');
  247. for (int i = 0; i < 10000; i++) {
  248. Document d;
  249. d.id = "d" + std::to_string(i);
  250. d.data = nlohmann::json{{"value", bigString}, {"i", i}};
  251. store.insert("docs", d);
  252. }
  253. auto path = mgr.createSnapshot(store, 1);
  254. store.stop();
  255. MemoryStore store2(storeCfg);
  256. store2.start();
  257. uint64_t seq = mgr.loadSnapshot(path, store2);
  258. assert(seq == 1);
  259. auto d = store2.get("docs", "d9999");
  260. assert(d.has_value());
  261. assert((*d).data["i"].get<int>() == 9999);
  262. store2.stop();
  263. fs::remove_all(dir);
  264. std::cout << "PASS: v4 chunked format round-trips at scale (10K docs, 20MB+)\n";
  265. }
  266. // ──────────────────────────────────────────────────────────
  267. // Test 8: WAL sequence anchoring (regression for v1.7.2)
  268. //
  269. // After a snapshot at sequence S, truncateBefore(S) deletes every WAL
  270. // file (because all entries have sequence ≤ S). The next boot's
  271. // wal_->open() finds no files, so sequence_ stays at 0. Subsequent
  272. // appends would produce 1, 2, 3, … which look "older than the snapshot"
  273. // to the boot AFTER that and get silently dropped by recovery's
  274. // `replay(snapSeq)` filter.
  275. //
  276. // PersistenceManager::replayWal now calls wal_->setSequenceFloor(snapSeq)
  277. // to anchor the WAL counter to the snapshot's walSequence — this test
  278. // exercises that anchoring directly on the WAL.
  279. //
  280. // Bug fingerprint on Zoe (2026-05-07): messages from 10:55–11:41 lost
  281. // across the 11:42 restart; same migration applied twice across the
  282. // two boots because the migrations-applied marker was written with
  283. // sequence 1 and discarded by the next recovery.
  284. // ──────────────────────────────────────────────────────────
  285. void test_wal_sequence_floor_anchors_after_truncate() {
  286. using smartbotic::database::WriteAheadLog;
  287. auto dir = makeSnapshotDir("wal-floor");
  288. WriteAheadLog::Config cfg;
  289. cfg.walDir = dir;
  290. // Phase 1 — fresh WAL, append 5 entries, snapshot at seq=5,
  291. // truncate everything ≤ 5. Mirrors the steady-state cycle.
  292. {
  293. WriteAheadLog wal(cfg);
  294. assert(wal.open());
  295. for (int i = 0; i < 5; ++i) {
  296. auto entry = WriteAheadLog::makeInsertEntry(
  297. "test", makeDoc("d" + std::to_string(i)), "test-node");
  298. uint64_t seq = wal.append(std::move(entry));
  299. assert(seq == static_cast<uint64_t>(i + 1));
  300. }
  301. assert(wal.currentSequence() == 5);
  302. wal.truncateBefore(5);
  303. wal.close();
  304. }
  305. // After truncate the on-disk WAL files are deleted. The next boot
  306. // simulates the post-snapshot startup.
  307. {
  308. WriteAheadLog wal(cfg);
  309. assert(wal.open());
  310. // Without anchoring, sequence_ would be 0 here — that's the bug.
  311. // The recovery path is responsible for raising the floor; the
  312. // WAL itself has no way to know about the snapshot.
  313. assert(wal.currentSequence() == 0);
  314. // Now anchor to the snapshot's walSequence (5).
  315. wal.setSequenceFloor(5);
  316. assert(wal.currentSequence() == 5);
  317. // setSequenceFloor must be monotonic — calling with a lower
  318. // value is a no-op.
  319. wal.setSequenceFloor(3);
  320. assert(wal.currentSequence() == 5);
  321. // Subsequent appends produce 6, 7, … — strictly greater than
  322. // the snapshot's walSequence, so the next boot's recovery
  323. // (which calls `replay(snapSeq)` with snapSeq=5) will see them.
  324. auto entry = WriteAheadLog::makeInsertEntry(
  325. "test", makeDoc("post"), "test-node");
  326. uint64_t seq = wal.append(std::move(entry));
  327. assert(seq == 6);
  328. wal.close();
  329. }
  330. // Phase 2 — re-open one more time, verify sequence_ recovers from
  331. // the new on-disk file at 6 (open() reads the highest entry).
  332. {
  333. WriteAheadLog wal(cfg);
  334. assert(wal.open());
  335. assert(wal.currentSequence() >= 6);
  336. wal.close();
  337. }
  338. fs::remove_all(dir);
  339. std::cout << "PASS: WAL sequence floor anchors after snapshot truncate\n";
  340. }
  341. int main() {
  342. test_atomic_write_leaves_no_tmp();
  343. test_snapshot_round_trip();
  344. test_verification_catches_truncation();
  345. test_loadWithFallback_uses_older_when_newest_corrupt();
  346. test_orphaned_tmp_cleaned_by_listSnapshots();
  347. test_loadWithFallback_throws_when_all_corrupt();
  348. test_v4_format_roundtrip_at_scale();
  349. test_wal_sequence_floor_anchors_after_truncate();
  350. std::cout << "\nAll snapshot durability tests PASSED!\n";
  351. return 0;
  352. }