|
|
@@ -26,6 +26,7 @@
|
|
|
#include <nlohmann/json.hpp>
|
|
|
#include <thread>
|
|
|
#include <unistd.h> // getpid
|
|
|
+#include <unordered_set>
|
|
|
|
|
|
namespace fs = std::filesystem;
|
|
|
using smartbotic::database::MemoryStore;
|
|
|
@@ -400,6 +401,128 @@ void test_wal_sequence_floor_anchors_after_truncate() {
|
|
|
std::cout << "PASS: WAL sequence floor anchors after snapshot truncate\n";
|
|
|
}
|
|
|
|
|
|
+// ──────────────────────────────────────────────────────────
|
|
|
+// Test: snapshot must include evicted documents (v1.7.3 fix)
|
|
|
+// ──────────────────────────────────────────────────────────
|
|
|
+//
|
|
|
+// Pre-v1.7.3: serializeStore() walked only `coll->documents` (in-memory),
|
|
|
+// silently omitting docs that had been evicted to stubs. Each snapshot
|
|
|
+// rotation truncated the WAL past the snapshot's sequence, deleting the
|
|
|
+// only remaining copy of those evicted docs — they were lost forever
|
|
|
+// across the next restart.
|
|
|
+void test_snapshot_includes_evicted_documents() {
|
|
|
+ using smartbotic::database::WriteAheadLog;
|
|
|
+ auto snapDir = makeSnapshotDir("evict-snap");
|
|
|
+ auto walDir = makeSnapshotDir("evict-wal");
|
|
|
+
|
|
|
+ WriteAheadLog::Config walCfg;
|
|
|
+ walCfg.walDir = walDir;
|
|
|
+ WriteAheadLog wal(walCfg);
|
|
|
+ if (!wal.open()) {
|
|
|
+ std::cerr << "FAIL: wal.open() returned false\n";
|
|
|
+ std::exit(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Tiny memory limit so eviction fires under load.
|
|
|
+ MemoryStore::Config storeCfg;
|
|
|
+ storeCfg.nodeId = "test";
|
|
|
+ storeCfg.maxMemoryBytes = 32ULL * 1024;
|
|
|
+ storeCfg.evictionThresholdPercent = 50;
|
|
|
+ storeCfg.evictionTargetPercent = 30;
|
|
|
+ storeCfg.hotWriteFloorMs = 0; // disable hot-write protection
|
|
|
+ storeCfg.evictionChunkSize = 1000;
|
|
|
+
|
|
|
+ MemoryStore store(storeCfg);
|
|
|
+ store.start();
|
|
|
+ store.createCollection("docs", CollectionOptions{});
|
|
|
+
|
|
|
+ // Wire load callback: scan WAL for the requested doc, same path
|
|
|
+ // production uses (database_service.cpp installs a similar lambda
|
|
|
+ // backed by PersistenceManager::loadDocument).
|
|
|
+ store.setDocumentLoadCallback(
|
|
|
+ [&wal](const std::string& coll, const std::string& id,
|
|
|
+ uint64_t /*walSeq*/) -> std::optional<Document> {
|
|
|
+ std::optional<Document> found;
|
|
|
+ wal.replay(0, [&](const smartbotic::database::WalEntry& e) {
|
|
|
+ if (e.collection == coll && e.documentId == id && e.data.has_value()) {
|
|
|
+ Document d = Document::fromJson(*e.data);
|
|
|
+ d.id = id;
|
|
|
+ d.collection = coll;
|
|
|
+ found = d;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return found;
|
|
|
+ });
|
|
|
+
|
|
|
+ // Insert N docs, mirroring to WAL so the load callback can resolve them.
|
|
|
+ constexpr int N = 100;
|
|
|
+ for (int i = 0; i < N; ++i) {
|
|
|
+ Document d = makeDoc("doc-" + std::to_string(i));
|
|
|
+ d.data["padding"] = std::string(300, 'x'); // make eviction meaningful
|
|
|
+ wal.append(WriteAheadLog::makeInsertEntry("docs", d, "test"));
|
|
|
+ store.insert("docs", d);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Force eviction.
|
|
|
+ store.evictDocuments();
|
|
|
+ auto evictedIds = store.getEvictedDocumentIds("docs");
|
|
|
+ auto inMemoryDocs = store.getAllDocuments("docs");
|
|
|
+ assert(!evictedIds.empty());
|
|
|
+ assert(evictedIds.size() + inMemoryDocs.size() == static_cast<size_t>(N));
|
|
|
+
|
|
|
+ // Flush WAL so the load callback (which reads via ifstream) sees every
|
|
|
+ // entry. In production the WAL is synced periodically by the persistence
|
|
|
+ // manager; tests need to force it.
|
|
|
+ wal.sync();
|
|
|
+
|
|
|
+ // Take snapshot.
|
|
|
+ SnapshotManager::Config scfg;
|
|
|
+ scfg.snapshotDir = snapDir;
|
|
|
+ SnapshotManager mgr(scfg);
|
|
|
+ auto path = mgr.createSnapshot(store, wal.currentSequence());
|
|
|
+
|
|
|
+ // Load into fresh store with a huge memory budget so eviction doesn't
|
|
|
+ // fire during verification — we want to see every doc in-memory.
|
|
|
+ MemoryStore::Config newCfg = storeCfg;
|
|
|
+ newCfg.maxMemoryBytes = 256ULL * 1024 * 1024;
|
|
|
+ MemoryStore newStore(newCfg);
|
|
|
+ newStore.start();
|
|
|
+ mgr.loadSnapshot(path, newStore);
|
|
|
+
|
|
|
+ auto loadedDocs = newStore.getAllDocuments("docs");
|
|
|
+ auto loadedEvicted = newStore.getEvictedDocumentIds("docs");
|
|
|
+ const size_t totalLoaded = loadedDocs.size() + loadedEvicted.size();
|
|
|
+
|
|
|
+ // Real check (assert is a no-op in Release builds with NDEBUG).
|
|
|
+ if (totalLoaded != static_cast<size_t>(N)) {
|
|
|
+ std::cerr << "FAIL: snapshot lost docs — loaded "
|
|
|
+ << loadedDocs.size() << " in-memory + "
|
|
|
+ << loadedEvicted.size() << " evicted = "
|
|
|
+ << totalLoaded << " of " << N << " inserted\n";
|
|
|
+ std::exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ std::unordered_set<std::string> loadedIdSet;
|
|
|
+ for (const auto& d : loadedDocs) loadedIdSet.insert(d.id);
|
|
|
+ for (const auto& id : loadedEvicted) loadedIdSet.insert(id);
|
|
|
+ for (const auto& id : evictedIds) {
|
|
|
+ if (loadedIdSet.count(id) != 1) {
|
|
|
+ std::cerr << "FAIL: evicted doc " << id
|
|
|
+ << " missing from loaded snapshot\n";
|
|
|
+ std::exit(1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ wal.close();
|
|
|
+ fs::remove_all(snapDir);
|
|
|
+ fs::remove_all(walDir);
|
|
|
+
|
|
|
+ std::cout << "PASS: snapshot includes evicted documents ("
|
|
|
+ << inMemoryDocs.size() << " in-memory + "
|
|
|
+ << evictedIds.size() << " evicted, all "
|
|
|
+ << N << " round-tripped)\n";
|
|
|
+}
|
|
|
+
|
|
|
int main() {
|
|
|
test_atomic_write_leaves_no_tmp();
|
|
|
test_snapshot_round_trip();
|
|
|
@@ -409,6 +532,7 @@ int main() {
|
|
|
test_loadWithFallback_throws_when_all_corrupt();
|
|
|
test_v4_format_roundtrip_at_scale();
|
|
|
test_wal_sequence_floor_anchors_after_truncate();
|
|
|
+ test_snapshot_includes_evicted_documents();
|
|
|
std::cout << "\nAll snapshot durability tests PASSED!\n";
|
|
|
return 0;
|
|
|
}
|