/** * Integration tests for v1.6.1 snapshot durability + fallback. * * Exercises SnapshotManager directly (no gRPC server). * * Test cases: * 1. atomic write leaves no .tmp file behind * 2. snapshot round-trip (write then load back) * 3. post-write verification catches externally-truncated file * 4. loadWithFallback uses older snapshot when newest is corrupt * 5. orphaned .tmp files cleaned by listSnapshots * 6. loadWithFallback throws when all snapshots corrupt */ #include "../service/src/persistence/snapshot.hpp" #include "../service/src/memory_store.hpp" #include "../service/src/document.hpp" #include #include #include #include #include #include #include #include #include // getpid namespace fs = std::filesystem; using smartbotic::database::MemoryStore; using smartbotic::database::SnapshotManager; using smartbotic::database::Document; using smartbotic::database::CollectionOptions; namespace { MemoryStore::Config defaultStoreConfig() { MemoryStore::Config cfg; cfg.nodeId = "test"; cfg.maxMemoryBytes = 256ULL * 1024 * 1024; return cfg; } fs::path makeSnapshotDir(const std::string& name) { auto dir = fs::temp_directory_path() / ("snap-durability-" + name + "-" + std::to_string(::getpid())); fs::remove_all(dir); fs::create_directories(dir); return dir; } Document makeDoc(const std::string& id) { Document d; d.id = id; d.data = nlohmann::json{{"value", id}}; return d; } } // anonymous namespace // ────────────────────────────────────────────────────────── // Test 1: atomic write leaves no .tmp file behind // ────────────────────────────────────────────────────────── void test_atomic_write_leaves_no_tmp() { auto dir = makeSnapshotDir("atomic"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; scfg.validateAfterWrite = true; SnapshotManager mgr(scfg); MemoryStore store(defaultStoreConfig()); store.start(); store.createCollection("docs", CollectionOptions{}); for (int i = 0; i < 50; i++) { store.insert("docs", makeDoc("d" + std::to_string(i))); } auto path = mgr.createSnapshot(store, 42); // No .tmp files should remain int tmpCount = 0; for (auto& e : fs::directory_iterator(dir)) { if (e.path().extension() == ".tmp") tmpCount++; } assert(tmpCount == 0); // The final file should exist and pass verification assert(fs::exists(path)); assert(mgr.verifySnapshot(path)); store.stop(); fs::remove_all(dir); std::cout << "PASS: atomic write leaves no .tmp file behind\n"; } // ────────────────────────────────────────────────────────── // Test 2: snapshot round-trips (write then load back) // ────────────────────────────────────────────────────────── void test_snapshot_round_trip() { auto dir = makeSnapshotDir("roundtrip"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; SnapshotManager mgr(scfg); MemoryStore store(defaultStoreConfig()); store.start(); store.createCollection("docs", CollectionOptions{}); for (int i = 0; i < 20; i++) { store.insert("docs", makeDoc("d" + std::to_string(i))); } auto path = mgr.createSnapshot(store, 99); store.stop(); MemoryStore store2(defaultStoreConfig()); store2.start(); uint64_t seq = mgr.loadSnapshot(path, store2); assert(seq == 99); auto doc = store2.get("docs", "d5"); assert(doc.has_value()); store2.stop(); fs::remove_all(dir); std::cout << "PASS: snapshot round-trip\n"; } // ────────────────────────────────────────────────────────── // Test 3: post-write verification catches externally-truncated file // ────────────────────────────────────────────────────────── void test_verification_catches_truncation() { auto dir = makeSnapshotDir("truncate"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; SnapshotManager mgr(scfg); MemoryStore store(defaultStoreConfig()); store.start(); store.createCollection("docs", CollectionOptions{}); for (int i = 0; i < 50; i++) { store.insert("docs", makeDoc("d" + std::to_string(i))); } auto path = mgr.createSnapshot(store, 1); store.stop(); // Truncate the file AFTER a successful write to simulate the Zoe scenario uintmax_t original = fs::file_size(path); fs::resize_file(path, original / 2); // verifySnapshot should return false assert(!mgr.verifySnapshot(path)); fs::remove_all(dir); std::cout << "PASS: post-write verification catches truncation\n"; } // ────────────────────────────────────────────────────────── // Test 4: loadWithFallback uses older snapshot when newest is corrupt // ────────────────────────────────────────────────────────── void test_loadWithFallback_uses_older_when_newest_corrupt() { auto dir = makeSnapshotDir("fallback"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; scfg.maxSnapshots = 10; SnapshotManager mgr(scfg); MemoryStore store(defaultStoreConfig()); store.start(); store.createCollection("docs", CollectionOptions{}); // Older snapshot — 10 docs for (int i = 0; i < 10; i++) { store.insert("docs", makeDoc("d" + std::to_string(i))); } auto older = mgr.createSnapshot(store, 100); // Filenames include timestamps down to the second, so sleep to ensure ordering std::this_thread::sleep_for(std::chrono::seconds(1)); // Newer snapshot — 20 docs for (int i = 10; i < 20; i++) { store.insert("docs", makeDoc("d" + std::to_string(i))); } auto newer = mgr.createSnapshot(store, 200); store.stop(); assert(older != newer); // Corrupt the newer fs::resize_file(newer, fs::file_size(newer) / 2); assert(!mgr.verifySnapshot(newer)); // loadWithFallback should transparently fall back to the older MemoryStore store2(defaultStoreConfig()); store2.start(); fs::path used; size_t attempted = 0; uint64_t seq = mgr.loadWithFallback(store2, &used, &attempted); assert(seq == 100); assert(used == older); assert(attempted == 2); // tried newer first, then older store2.stop(); fs::remove_all(dir); std::cout << "PASS: loadWithFallback uses older snapshot when newest corrupt\n"; } // ────────────────────────────────────────────────────────── // Test 5: orphaned .tmp files cleaned by listSnapshots() // ────────────────────────────────────────────────────────── void test_orphaned_tmp_cleaned_by_listSnapshots() { auto dir = makeSnapshotDir("orphan"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; SnapshotManager mgr(scfg); // Manually plant a fake .tmp like a crashed-mid-write would leave auto tmp = dir / "snapshot-20260101-120000.dat.tmp"; { std::ofstream f(tmp); f << "partial"; } assert(fs::exists(tmp)); // listSnapshots should clean up the .tmp on its pre-scan pass (void)mgr.listSnapshots(); assert(!fs::exists(tmp)); fs::remove_all(dir); std::cout << "PASS: orphaned .tmp cleaned by listSnapshots\n"; } // ────────────────────────────────────────────────────────── // Test 6: loadWithFallback throws when all snapshots corrupt // ────────────────────────────────────────────────────────── void test_loadWithFallback_throws_when_all_corrupt() { auto dir = makeSnapshotDir("allcorrupt"); SnapshotManager::Config scfg; scfg.snapshotDir = dir; scfg.maxSnapshots = 10; SnapshotManager mgr(scfg); MemoryStore store(defaultStoreConfig()); store.start(); store.createCollection("docs", CollectionOptions{}); store.insert("docs", makeDoc("d1")); auto s1 = mgr.createSnapshot(store, 1); std::this_thread::sleep_for(std::chrono::seconds(1)); store.insert("docs", makeDoc("d2")); auto s2 = mgr.createSnapshot(store, 2); store.stop(); // Corrupt both fs::resize_file(s1, fs::file_size(s1) / 2); fs::resize_file(s2, fs::file_size(s2) / 2); MemoryStore store2(defaultStoreConfig()); store2.start(); bool threw = false; try { mgr.loadWithFallback(store2); } catch (const std::exception& e) { threw = true; std::string msg = e.what(); assert(msg.find("all") != std::string::npos); } assert(threw); store2.stop(); fs::remove_all(dir); std::cout << "PASS: loadWithFallback throws when all snapshots corrupt\n"; } int main() { test_atomic_write_leaves_no_tmp(); test_snapshot_round_trip(); test_verification_catches_truncation(); test_loadWithFallback_uses_older_when_newest_corrupt(); test_orphaned_tmp_cleaned_by_listSnapshots(); test_loadWithFallback_throws_when_all_corrupt(); std::cout << "\nAll snapshot durability tests PASSED!\n"; return 0; }