snapshot.cpp 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  1. #include "snapshot.hpp"
  2. #include "wal.hpp"
  3. #include "../json_parse.hpp"
  4. #include <algorithm>
  5. #include <atomic>
  6. #include <cerrno>
  7. #include <chrono>
  8. #include <cstring>
  9. #include <fstream>
  10. #include <iomanip>
  11. #include <sstream>
  12. #include <system_error>
  13. #include <thread>
  14. #include <fcntl.h>
  15. #include <unistd.h>
  16. #include <spdlog/spdlog.h>
  17. #ifndef DISABLE_LZ4
  18. #include <lz4.h>
  19. #endif
  20. #if defined(__GLIBC__) && !defined(__APPLE__)
  21. #include <malloc.h>
  22. #endif
  23. namespace {
  24. // v1.9.1 — `malloc_trim(0)` after a big-buffer phase finishes. The
  25. // snapshot write and load paths both build multi-GB transient buffers
  26. // (uncompressed body + LZ4 chunks; per-collection JSON parses). When
  27. // those buffers free, glibc parks the pages on its arena freelist and
  28. // only reclaims them lazily during subsequent small allocations. For a
  29. // quiescent post-boot or post-snapshot process those allocations may
  30. // not arrive for hours, so the operator-visible VmRSS stays inflated
  31. // even though `estimatedMemoryBytes_` is correct. Trimming explicitly
  32. // here turns a 2.8 GB no-op back into a 2.8 GB drop (measured on Zoe
  33. // 2026-05-13 12:07 via the SIGUSR2 handler from v1.8.3 — same call,
  34. // just automated). No-op on non-glibc platforms.
  35. void releaseFreelistPages() {
  36. #if defined(__GLIBC__) && !defined(__APPLE__)
  37. ::malloc_trim(0);
  38. #endif
  39. }
  40. } // namespace
  41. namespace smartbotic::database {
  42. namespace {
  43. // Decompress a v4-chunked LZ4 body produced by createSnapshot.
  44. // Body layout: sequence of [u32 uncomp_size, u32 comp_size, LZ4 bytes...] terminated
  45. // by a (0, 0) end marker. Returns the full concatenated uncompressed payload.
  46. std::vector<uint8_t> decompressChunked(const std::vector<uint8_t>& body,
  47. uint64_t uncompressedSize) {
  48. #ifndef DISABLE_LZ4
  49. std::vector<uint8_t> out;
  50. out.reserve(uncompressedSize);
  51. size_t offset = 0;
  52. while (offset + 8 <= body.size()) {
  53. uint32_t uncompU32 = 0;
  54. uint32_t compU32 = 0;
  55. std::memcpy(&uncompU32, body.data() + offset, 4);
  56. std::memcpy(&compU32, body.data() + offset + 4, 4);
  57. offset += 8;
  58. if (uncompU32 == 0 && compU32 == 0) {
  59. break; // end marker
  60. }
  61. if (offset + compU32 > body.size()) {
  62. throw std::runtime_error("Snapshot chunk extends past body");
  63. }
  64. size_t outStart = out.size();
  65. out.resize(outStart + uncompU32);
  66. int decompressed = LZ4_decompress_safe(
  67. reinterpret_cast<const char*>(body.data() + offset),
  68. reinterpret_cast<char*>(out.data() + outStart),
  69. static_cast<int>(compU32),
  70. static_cast<int>(uncompU32)
  71. );
  72. if (decompressed < 0 || static_cast<uint32_t>(decompressed) != uncompU32) {
  73. throw std::runtime_error("Snapshot chunk decompression failed");
  74. }
  75. offset += compU32;
  76. }
  77. if (out.size() != uncompressedSize) {
  78. throw std::runtime_error("Decompressed size mismatch: expected " +
  79. std::to_string(uncompressedSize) + " got " +
  80. std::to_string(out.size()));
  81. }
  82. return out;
  83. #else
  84. (void)body;
  85. (void)uncompressedSize;
  86. throw std::runtime_error("LZ4 disabled — cannot decompress chunked snapshot body");
  87. #endif
  88. }
  89. } // namespace
  90. SnapshotManager::SnapshotManager(Config config)
  91. : config_(std::move(config)) {
  92. // Create snapshot directory if it doesn't exist
  93. std::error_code ec;
  94. std::filesystem::create_directories(config_.snapshotDir, ec);
  95. }
  96. std::filesystem::path SnapshotManager::createSnapshot(const MemoryStore& store, uint64_t walSequence) {
  97. // Serialize store data
  98. uint64_t docCount = 0;
  99. uint32_t collCount = 0;
  100. std::vector<uint8_t> uncompressedData = serializeStore(store, docCount, collCount);
  101. const uint64_t originalUncompressedSize = uncompressedData.size();
  102. // Build chunked body with streaming compression.
  103. // v4 body layout: sequence of [u32 uncompressed_size, u32 compressed_size, bytes...]
  104. // terminated by a (0, 0) end marker. Peak memory per chunk is ~16 MB output buffer
  105. // instead of LZ4_compressBound(full DB) which used to be ~3 GB for production sizes.
  106. constexpr size_t CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB uncompressed per chunk
  107. std::vector<uint8_t> bodyData;
  108. uint32_t compressionType = 0;
  109. if (config_.compressionEnabled) {
  110. #ifndef DISABLE_LZ4
  111. compressionType = 1;
  112. // Rough estimate to reduce reallocation thrash; actual size depends on payload compressibility.
  113. bodyData.reserve(uncompressedData.size() / 2 + CHUNK_SIZE);
  114. size_t offset = 0;
  115. while (offset < uncompressedData.size()) {
  116. size_t thisChunk = std::min(CHUNK_SIZE, uncompressedData.size() - offset);
  117. int compressBound = LZ4_compressBound(static_cast<int>(thisChunk));
  118. std::vector<uint8_t> chunkOut(compressBound);
  119. int compressedSize = LZ4_compress_default(
  120. reinterpret_cast<const char*>(uncompressedData.data() + offset),
  121. reinterpret_cast<char*>(chunkOut.data()),
  122. static_cast<int>(thisChunk),
  123. compressBound
  124. );
  125. if (compressedSize <= 0) {
  126. throw std::runtime_error("LZ4 compression failed at offset " + std::to_string(offset));
  127. }
  128. // Append length prefixes and compressed data to bodyData
  129. uint32_t uncompU32 = static_cast<uint32_t>(thisChunk);
  130. uint32_t compU32 = static_cast<uint32_t>(compressedSize);
  131. const uint8_t* uncompBytes = reinterpret_cast<const uint8_t*>(&uncompU32);
  132. const uint8_t* compBytes = reinterpret_cast<const uint8_t*>(&compU32);
  133. bodyData.insert(bodyData.end(), uncompBytes, uncompBytes + 4);
  134. bodyData.insert(bodyData.end(), compBytes, compBytes + 4);
  135. bodyData.insert(bodyData.end(), chunkOut.begin(), chunkOut.begin() + compressedSize);
  136. offset += thisChunk;
  137. }
  138. // End marker: two zero u32s
  139. uint32_t zero = 0;
  140. const uint8_t* zeroBytes = reinterpret_cast<const uint8_t*>(&zero);
  141. bodyData.insert(bodyData.end(), zeroBytes, zeroBytes + 4);
  142. bodyData.insert(bodyData.end(), zeroBytes, zeroBytes + 4);
  143. // Free the uncompressed buffer — we no longer need it
  144. std::vector<uint8_t>().swap(uncompressedData);
  145. #else
  146. bodyData = std::move(uncompressedData);
  147. compressionType = 0;
  148. #endif
  149. } else {
  150. bodyData = std::move(uncompressedData);
  151. }
  152. // Build header
  153. SnapshotHeader header;
  154. header.timestamp = static_cast<uint64_t>(
  155. std::chrono::duration_cast<std::chrono::milliseconds>(
  156. std::chrono::system_clock::now().time_since_epoch()
  157. ).count()
  158. );
  159. header.walSequence = walSequence;
  160. header.documentCount = docCount;
  161. header.collectionCount = collCount;
  162. header.uncompressedSize = originalUncompressedSize;
  163. header.compressedSize = bodyData.size();
  164. header.compressionType = compressionType;
  165. // Calculate header checksum (excluding the checksum field itself)
  166. header.headerChecksum = crc32::calculate(&header, sizeof(header) - sizeof(header.headerChecksum));
  167. // Generate filename — write to .tmp first, atomic rename at the end
  168. std::filesystem::path snapshotPath = config_.snapshotDir / generateFilename();
  169. std::filesystem::path tmpPath = snapshotPath;
  170. tmpPath += ".tmp";
  171. // Helper to remove tmp on error paths
  172. auto cleanupTmp = [&tmpPath]() {
  173. std::error_code ec;
  174. std::filesystem::remove(tmpPath, ec);
  175. };
  176. // Write to temp file
  177. {
  178. std::ofstream file(tmpPath, std::ios::binary | std::ios::trunc);
  179. if (!file) {
  180. throw std::runtime_error("Failed to create snapshot tmp file: " + tmpPath.string());
  181. }
  182. // Header + short-write check
  183. file.write(reinterpret_cast<const char*>(&header), sizeof(header));
  184. if (!file) {
  185. cleanupTmp();
  186. throw std::runtime_error("Failed to write snapshot header: " + tmpPath.string());
  187. }
  188. // Body + short-write check
  189. file.write(reinterpret_cast<const char*>(bodyData.data()),
  190. static_cast<std::streamsize>(bodyData.size()));
  191. if (!file) {
  192. cleanupTmp();
  193. throw std::runtime_error("Failed to write snapshot body (short write): " + tmpPath.string());
  194. }
  195. // Trailer (body CRC) + short-write check
  196. uint32_t bodyChecksum = crc32::calculate(bodyData.data(), bodyData.size());
  197. file.write(reinterpret_cast<const char*>(&bodyChecksum), sizeof(bodyChecksum));
  198. if (!file) {
  199. cleanupTmp();
  200. throw std::runtime_error("Failed to write snapshot trailer: " + tmpPath.string());
  201. }
  202. // Flush C++ stream buffer
  203. file.flush();
  204. if (!file) {
  205. cleanupTmp();
  206. throw std::runtime_error("Failed to flush snapshot: " + tmpPath.string());
  207. }
  208. } // ofstream destructor runs close()
  209. // fsync the file descriptor — C++ ofstream does not do this
  210. {
  211. int fd = ::open(tmpPath.c_str(), O_RDONLY);
  212. if (fd < 0) {
  213. cleanupTmp();
  214. throw std::runtime_error("Failed to reopen snapshot tmp for fsync: " + tmpPath.string());
  215. }
  216. if (::fsync(fd) != 0) {
  217. int saved_errno = errno;
  218. ::close(fd);
  219. cleanupTmp();
  220. throw std::runtime_error("fsync failed on snapshot tmp (errno=" +
  221. std::to_string(saved_errno) + "): " + tmpPath.string());
  222. }
  223. ::close(fd);
  224. }
  225. // Atomic rename — from this point on, either the final file exists and is complete,
  226. // or the .tmp is gone and no snapshot was produced.
  227. {
  228. std::error_code ec;
  229. std::filesystem::rename(tmpPath, snapshotPath, ec);
  230. if (ec) {
  231. cleanupTmp();
  232. throw std::runtime_error("Snapshot rename failed: " + ec.message());
  233. }
  234. }
  235. // fsync the containing directory so the rename is durable after crash
  236. {
  237. int dfd = ::open(config_.snapshotDir.c_str(), O_RDONLY | O_DIRECTORY);
  238. if (dfd >= 0) {
  239. ::fsync(dfd);
  240. ::close(dfd);
  241. }
  242. // Non-fatal on failure — some filesystems reject O_DIRECTORY
  243. }
  244. // Post-write verification (opt-out via config)
  245. bool verified = true;
  246. if (config_.validateAfterWrite) {
  247. if (!verifySnapshot(snapshotPath)) {
  248. // The file exists on disk but is corrupt — delete it so it can't be used
  249. std::error_code ec;
  250. std::filesystem::remove(snapshotPath, ec);
  251. throw std::runtime_error("Snapshot verification failed after write: " + snapshotPath.string());
  252. }
  253. spdlog::debug("Snapshot verified after write: {}", snapshotPath.string());
  254. }
  255. // Cleanup — only evict old snapshots if the new one passed verification
  256. // (or if cleanup_only_if_verified is disabled)
  257. if (verified || !config_.cleanupOnlyIfVerified) {
  258. cleanupOldSnapshots();
  259. }
  260. // v1.9.1 — release freelist pages after the snapshot write. We just
  261. // built and threw away the uncompressed serialized buffer
  262. // (potentially several GB) plus LZ4 chunk staging buffers; same
  263. // post-burst freelist-retention story as the boot-time load above.
  264. // Trimming here keeps the periodic snapshot path from drifting RSS
  265. // up over the day. Cheap relative to the snapshot write itself.
  266. releaseFreelistPages();
  267. return snapshotPath;
  268. }
  269. uint64_t SnapshotManager::loadLatestSnapshot(MemoryStore& store) {
  270. auto snapshots = listSnapshots();
  271. if (snapshots.empty()) {
  272. return 0;
  273. }
  274. return loadSnapshot(snapshots.front(), store);
  275. }
  276. uint64_t SnapshotManager::loadWithFallback(MemoryStore& store,
  277. std::filesystem::path* outUsed,
  278. size_t* outAttempted) {
  279. auto snapshots = listSnapshots(); // already sorted newest-first
  280. if (outAttempted) *outAttempted = 0;
  281. if (snapshots.empty()) {
  282. throw std::runtime_error("loadWithFallback: no snapshots available");
  283. }
  284. std::string lastError;
  285. for (size_t i = 0; i < snapshots.size(); ++i) {
  286. const auto& path = snapshots[i];
  287. if (outAttempted) (*outAttempted)++;
  288. try {
  289. uint64_t seq = loadSnapshot(path, store);
  290. if (outUsed) *outUsed = path;
  291. if (i > 0) {
  292. spdlog::error(
  293. "Loaded fallback snapshot (index {} of {}): {} "
  294. "— newer snapshots were corrupt",
  295. i, snapshots.size(), path.string());
  296. }
  297. return seq;
  298. } catch (const std::exception& e) {
  299. lastError = e.what();
  300. spdlog::warn("Snapshot load failed for {}: {} — trying next older",
  301. path.string(), e.what());
  302. // Note: store.clear() is called by loadSnapshot() before writing,
  303. // so a partial load followed by retry on the next snapshot is safe.
  304. }
  305. }
  306. throw std::runtime_error("loadWithFallback: all " +
  307. std::to_string(snapshots.size()) +
  308. " snapshots failed to load (last error: " +
  309. lastError + ")");
  310. }
  311. uint64_t SnapshotManager::loadSnapshot(const std::filesystem::path& path, MemoryStore& store) {
  312. std::ifstream file(path, std::ios::binary);
  313. if (!file) {
  314. throw std::runtime_error("Failed to open snapshot file: " + path.string());
  315. }
  316. // Read header
  317. SnapshotHeader header;
  318. file.read(reinterpret_cast<char*>(&header), sizeof(header));
  319. if (!file) {
  320. throw std::runtime_error("Failed to read snapshot header");
  321. }
  322. // Verify magic
  323. if (std::memcmp(header.magic, "CALSNAP1", 8) != 0) {
  324. throw std::runtime_error("Invalid snapshot file magic");
  325. }
  326. // Verify header checksum
  327. uint32_t expectedHeaderCrc = crc32::calculate(&header, sizeof(header) - sizeof(header.headerChecksum));
  328. if (header.headerChecksum != expectedHeaderCrc) {
  329. throw std::runtime_error("Snapshot header checksum mismatch");
  330. }
  331. // Read body
  332. std::vector<uint8_t> bodyData(header.compressedSize);
  333. file.read(reinterpret_cast<char*>(bodyData.data()),
  334. static_cast<std::streamsize>(bodyData.size()));
  335. if (!file) {
  336. throw std::runtime_error("Failed to read snapshot body");
  337. }
  338. // Read and verify body checksum
  339. uint32_t storedBodyCrc;
  340. file.read(reinterpret_cast<char*>(&storedBodyCrc), sizeof(storedBodyCrc));
  341. uint32_t calculatedBodyCrc = crc32::calculate(bodyData.data(), bodyData.size());
  342. if (storedBodyCrc != calculatedBodyCrc) {
  343. throw std::runtime_error("Snapshot body checksum mismatch");
  344. }
  345. // Decompress if needed
  346. std::vector<uint8_t> uncompressedData;
  347. if (header.compressionType == 1) {
  348. if (header.version >= 4) {
  349. // v4: chunked LZ4 body
  350. uncompressedData = decompressChunked(bodyData, header.uncompressedSize);
  351. } else {
  352. // v3 and earlier: single LZ4 block
  353. uncompressedData = decompress(bodyData, header.uncompressedSize);
  354. if (uncompressedData.empty()) {
  355. throw std::runtime_error("Failed to decompress snapshot");
  356. }
  357. }
  358. } else {
  359. uncompressedData = std::move(bodyData);
  360. }
  361. // Clear store and deserialize
  362. store.clear();
  363. deserializeStore(uncompressedData, store, header.version);
  364. // v1.9.1 — release allocator freelist pages now that the multi-GB
  365. // transient buffers (decompressed body + per-doc JSON parses) are
  366. // gone. Without this, glibc keeps those pages parked on the per-
  367. // thread freelist; VmRSS stays high until subsequent small allocs
  368. // happen to land in the right bin. On Zoe (5 GB snapshot) this
  369. // drops post-boot RSS by ~2.8 GB.
  370. releaseFreelistPages();
  371. return header.walSequence;
  372. }
  373. std::vector<std::filesystem::path> SnapshotManager::listSnapshots() const {
  374. std::vector<std::pair<uint64_t, std::filesystem::path>> snapshots;
  375. // Clean up .tmp files left from crashed writes (they start with "snapshot-" and end with ".tmp")
  376. {
  377. std::error_code scan_ec;
  378. for (const auto& entry : std::filesystem::directory_iterator(config_.snapshotDir, scan_ec)) {
  379. if (!entry.is_regular_file()) continue;
  380. const auto& p = entry.path();
  381. if (p.extension() == ".tmp" &&
  382. p.filename().string().starts_with("snapshot-")) {
  383. std::error_code rm_ec;
  384. std::filesystem::remove(p, rm_ec);
  385. spdlog::info("Removed orphaned snapshot tmp file: {}", p.string());
  386. }
  387. }
  388. }
  389. std::error_code ec;
  390. for (const auto& entry : std::filesystem::directory_iterator(config_.snapshotDir, ec)) {
  391. if (entry.is_regular_file()) {
  392. const auto& path = entry.path();
  393. if (path.filename().string().starts_with("snapshot-") &&
  394. path.extension() == ".dat") {
  395. // Read header to get timestamp
  396. std::ifstream file(path, std::ios::binary);
  397. if (file) {
  398. SnapshotHeader header;
  399. file.read(reinterpret_cast<char*>(&header), sizeof(header));
  400. if (file && std::memcmp(header.magic, "CALSNAP1", 8) == 0) {
  401. snapshots.emplace_back(header.timestamp, path);
  402. }
  403. }
  404. }
  405. }
  406. }
  407. // Sort by timestamp descending (newest first)
  408. std::sort(snapshots.begin(), snapshots.end(),
  409. [](const auto& a, const auto& b) { return a.first > b.first; });
  410. std::vector<std::filesystem::path> result;
  411. result.reserve(snapshots.size());
  412. for (const auto& [_, path] : snapshots) {
  413. result.push_back(path);
  414. }
  415. return result;
  416. }
  417. std::optional<SnapshotHeader> SnapshotManager::getSnapshotInfo(const std::filesystem::path& path) const {
  418. std::ifstream file(path, std::ios::binary);
  419. if (!file) {
  420. return std::nullopt;
  421. }
  422. SnapshotHeader header;
  423. file.read(reinterpret_cast<char*>(&header), sizeof(header));
  424. if (!file) {
  425. return std::nullopt;
  426. }
  427. if (std::memcmp(header.magic, "CALSNAP1", 8) != 0) {
  428. return std::nullopt;
  429. }
  430. return header;
  431. }
  432. void SnapshotManager::cleanupOldSnapshots() {
  433. auto snapshots = listSnapshots();
  434. // Delete snapshots beyond the max count
  435. while (snapshots.size() > config_.maxSnapshots) {
  436. const auto& oldestPath = snapshots.back();
  437. std::error_code ec;
  438. std::filesystem::remove(oldestPath, ec);
  439. snapshots.pop_back();
  440. }
  441. }
  442. bool SnapshotManager::verifySnapshot(const std::filesystem::path& path) const {
  443. try {
  444. std::ifstream file(path, std::ios::binary);
  445. if (!file) {
  446. return false;
  447. }
  448. // Read and verify header
  449. SnapshotHeader header;
  450. file.read(reinterpret_cast<char*>(&header), sizeof(header));
  451. if (!file) {
  452. return false;
  453. }
  454. if (std::memcmp(header.magic, "CALSNAP1", 8) != 0) {
  455. return false;
  456. }
  457. uint32_t expectedHeaderCrc = crc32::calculate(&header, sizeof(header) - sizeof(header.headerChecksum));
  458. if (header.headerChecksum != expectedHeaderCrc) {
  459. return false;
  460. }
  461. // Read and verify body
  462. std::vector<uint8_t> bodyData(header.compressedSize);
  463. file.read(reinterpret_cast<char*>(bodyData.data()),
  464. static_cast<std::streamsize>(bodyData.size()));
  465. if (!file) {
  466. return false;
  467. }
  468. uint32_t storedBodyCrc;
  469. file.read(reinterpret_cast<char*>(&storedBodyCrc), sizeof(storedBodyCrc));
  470. uint32_t calculatedBodyCrc = crc32::calculate(bodyData.data(), bodyData.size());
  471. return storedBodyCrc == calculatedBodyCrc;
  472. } catch (...) {
  473. return false;
  474. }
  475. }
  476. std::string SnapshotManager::generateFilename() const {
  477. auto now = std::chrono::system_clock::now();
  478. auto time = std::chrono::system_clock::to_time_t(now);
  479. auto tm = *std::localtime(&time);
  480. std::ostringstream oss;
  481. oss << "snapshot-"
  482. << std::put_time(&tm, "%Y%m%d-%H%M%S")
  483. << ".dat";
  484. return oss.str();
  485. }
  486. std::vector<uint8_t> SnapshotManager::compress(const std::vector<uint8_t>& data) const {
  487. #ifndef DISABLE_LZ4
  488. if (data.empty()) {
  489. return {};
  490. }
  491. int maxCompressedSize = LZ4_compressBound(static_cast<int>(data.size()));
  492. std::vector<uint8_t> compressed(maxCompressedSize);
  493. int compressedSize = LZ4_compress_default(
  494. reinterpret_cast<const char*>(data.data()),
  495. reinterpret_cast<char*>(compressed.data()),
  496. static_cast<int>(data.size()),
  497. maxCompressedSize
  498. );
  499. if (compressedSize <= 0) {
  500. return {}; // Compression failed
  501. }
  502. compressed.resize(compressedSize);
  503. return compressed;
  504. #else
  505. return {}; // Compression disabled
  506. #endif
  507. }
  508. std::vector<uint8_t> SnapshotManager::decompress(const std::vector<uint8_t>& data,
  509. uint64_t uncompressedSize) const {
  510. #ifndef DISABLE_LZ4
  511. if (data.empty() || uncompressedSize == 0) {
  512. return {};
  513. }
  514. std::vector<uint8_t> decompressed(uncompressedSize);
  515. int decompressedSize = LZ4_decompress_safe(
  516. reinterpret_cast<const char*>(data.data()),
  517. reinterpret_cast<char*>(decompressed.data()),
  518. static_cast<int>(data.size()),
  519. static_cast<int>(uncompressedSize)
  520. );
  521. if (decompressedSize < 0 || static_cast<uint64_t>(decompressedSize) != uncompressedSize) {
  522. return {}; // Decompression failed
  523. }
  524. return decompressed;
  525. #else
  526. return {}; // Compression disabled
  527. #endif
  528. }
  529. std::vector<uint8_t> SnapshotManager::serializeStore(const MemoryStore& store,
  530. uint64_t& docCount,
  531. uint32_t& collCount) const {
  532. std::vector<uint8_t> result;
  533. docCount = 0;
  534. collCount = 0;
  535. auto collections = store.getAllCollectionsWithOptions();
  536. collCount = static_cast<uint32_t>(collections.size());
  537. for (const auto& [name, options] : collections) {
  538. // Write collection name
  539. uint16_t nameLen = static_cast<uint16_t>(name.size());
  540. result.push_back(static_cast<uint8_t>(nameLen & 0xFF));
  541. result.push_back(static_cast<uint8_t>((nameLen >> 8) & 0xFF));
  542. result.insert(result.end(), name.begin(), name.end());
  543. // Write collection options
  544. std::string optionsJson = options.toJson().dump();
  545. uint32_t optionsLen = static_cast<uint32_t>(optionsJson.size());
  546. for (int i = 0; i < 4; ++i) {
  547. result.push_back(static_cast<uint8_t>((optionsLen >> (i * 8)) & 0xFF));
  548. }
  549. result.insert(result.end(), optionsJson.begin(), optionsJson.end());
  550. // Get all documents in collection. Snapshot must include both
  551. // in-memory docs AND docs that have been evicted to stubs — otherwise
  552. // truncateBefore(walSeq) after this snapshot deletes the WAL entries
  553. // that hold the only remaining copy of the evicted docs, and they're
  554. // gone forever. Pre-v1.7.3 only wrote in-memory docs; v1.7.0
  555. // introduced eviction without a corresponding snapshot path, so any
  556. // snapshot taken after eviction silently lost the evicted set.
  557. auto docs = store.getAllDocuments(name);
  558. auto evictedIds = store.getEvictedDocumentIds(name);
  559. // Reserve 8 bytes for the doc count and back-patch after writing,
  560. // so we can skip evicted docs that fail to load from WAL without
  561. // desynchronising the deserializer's counter.
  562. size_t countOffset = result.size();
  563. for (int i = 0; i < 8; ++i) result.push_back(0);
  564. uint64_t collDocCount = 0;
  565. // Write each in-memory document
  566. for (const auto& doc : docs) {
  567. std::string docJson = doc.toJson().dump();
  568. uint32_t docLen = static_cast<uint32_t>(docJson.size());
  569. for (int i = 0; i < 4; ++i) {
  570. result.push_back(static_cast<uint8_t>((docLen >> (i * 8)) & 0xFF));
  571. }
  572. result.insert(result.end(), docJson.begin(), docJson.end());
  573. collDocCount++;
  574. }
  575. // Write each evicted document, loading from WAL via the document
  576. // load callback. peekEvictedDocument is const and uses the same
  577. // path that read queries use to fault evicted docs back in.
  578. uint64_t evictedFailed = 0;
  579. for (const auto& id : evictedIds) {
  580. auto docOpt = store.peekEvictedDocument(name, id);
  581. if (!docOpt) {
  582. evictedFailed++;
  583. continue;
  584. }
  585. std::string docJson = docOpt->toJson().dump();
  586. uint32_t docLen = static_cast<uint32_t>(docJson.size());
  587. for (int i = 0; i < 4; ++i) {
  588. result.push_back(static_cast<uint8_t>((docLen >> (i * 8)) & 0xFF));
  589. }
  590. result.insert(result.end(), docJson.begin(), docJson.end());
  591. collDocCount++;
  592. }
  593. if (evictedFailed > 0) {
  594. spdlog::warn(
  595. "Snapshot: {} evicted documents in collection '{}' could not "
  596. "be loaded from WAL — they will be missing from the snapshot. "
  597. "Likely a pre-v1.7.3 snapshot already lost them.",
  598. evictedFailed, name);
  599. }
  600. // Back-patch the doc count
  601. for (int i = 0; i < 8; ++i) {
  602. result[countOffset + i] =
  603. static_cast<uint8_t>((collDocCount >> (i * 8)) & 0xFF);
  604. }
  605. docCount += collDocCount;
  606. // v1.9.0 — version history block is now an empty section. History
  607. // moved to disk-resident HistoryStore (separate `.hlog` files per
  608. // collection under <data_dir>/history/). We still emit the
  609. // 8-byte zero count so v1.8.x readers can parse the snapshot
  610. // (they'll find no entries and move on). v2.x can drop this
  611. // block entirely with a snapshot format bump.
  612. uint64_t historyEntryCount = 0;
  613. for (int i = 0; i < 8; ++i) {
  614. result.push_back(static_cast<uint8_t>((historyEntryCount >> (i * 8)) & 0xFF));
  615. }
  616. // Write vector data (v3)
  617. const auto* vectors = store.getCollectionVectors(name);
  618. uint64_t vecCount = (vectors != nullptr) ? static_cast<uint64_t>(vectors->size()) : 0;
  619. for (int i = 0; i < 8; ++i) {
  620. result.push_back(static_cast<uint8_t>((vecCount >> (i * 8)) & 0xFF));
  621. }
  622. if (vectors != nullptr) {
  623. for (const auto& [docId, vec] : *vectors) {
  624. // Write doc ID
  625. uint16_t docIdLen = static_cast<uint16_t>(docId.size());
  626. result.push_back(static_cast<uint8_t>(docIdLen & 0xFF));
  627. result.push_back(static_cast<uint8_t>((docIdLen >> 8) & 0xFF));
  628. result.insert(result.end(), docId.begin(), docId.end());
  629. // Write vector dimension
  630. uint32_t dim = static_cast<uint32_t>(vec.size());
  631. for (int i = 0; i < 4; ++i) {
  632. result.push_back(static_cast<uint8_t>((dim >> (i * 8)) & 0xFF));
  633. }
  634. // Write raw float data
  635. const uint8_t* floatBytes = reinterpret_cast<const uint8_t*>(vec.data());
  636. result.insert(result.end(), floatBytes, floatBytes + dim * sizeof(float));
  637. }
  638. }
  639. }
  640. return result;
  641. }
  642. namespace {
  643. // v1.8.0 — slice describing where one collection's bytes live in the
  644. // decompressed snapshot body, used by the two-phase parallel deserializer.
  645. struct CollectionSlice {
  646. std::string name;
  647. CollectionOptions options;
  648. uint64_t docCount = 0;
  649. size_t docSectionStart = 0;
  650. size_t docSectionEnd = 0;
  651. uint64_t historyEntryCount = 0;
  652. size_t historySectionStart = 0;
  653. size_t historySectionEnd = 0;
  654. uint64_t vecCount = 0;
  655. size_t vecSectionStart = 0;
  656. size_t vecSectionEnd = 0;
  657. };
  658. } // namespace
  659. void SnapshotManager::deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store,
  660. uint32_t snapshotVersion) const {
  661. // v1.8.0 — two-phase parallel deserializer. Before this, a 5 GB snapshot
  662. // with many JSON-encoded documents pinned a single core at 100% for tens
  663. // of seconds during boot. The serialized format is a concatenation of
  664. // independent per-collection blocks, so we:
  665. // 1. Walk the body once with pure byte arithmetic (no JSON parsing) to
  666. // record each collection's name, options, and the byte ranges that
  667. // hold its docs / history / vectors. This is cheap and stays in L2.
  668. // 2. Create the collections sequentially so global state (collection
  669. // map, options) is consistent before workers start.
  670. // 3. Spawn N worker threads (N = hardware_concurrency capped at the
  671. // number of collections) that pull work off a shared index counter
  672. // and parse + load each collection's payload in isolation. JSON
  673. // parsing — the actual hot path — runs in parallel; per-collection
  674. // MemoryStore mutexes mean no two workers contend on the same
  675. // collection's documents map.
  676. //
  677. // For workloads with many small/medium collections (e.g. ShadowMan, with
  678. // ~30 collections) this drops boot time roughly linearly with cores. For
  679. // a single-giant-collection workload there is no within-collection
  680. // parallelism yet; that would be a follow-up.
  681. // ===== Phase 1: scan body byte-by-byte to find collection slices =====
  682. std::vector<CollectionSlice> slices;
  683. size_t offset = 0;
  684. while (offset < data.size()) {
  685. CollectionSlice slice;
  686. // Collection name
  687. if (offset + 2 > data.size()) break;
  688. uint16_t nameLen = static_cast<uint16_t>(data[offset]) |
  689. (static_cast<uint16_t>(data[offset + 1]) << 8);
  690. offset += 2;
  691. if (offset + nameLen > data.size()) break;
  692. slice.name.assign(reinterpret_cast<const char*>(&data[offset]), nameLen);
  693. offset += nameLen;
  694. // Collection options (small JSON — keep parsing here, sequential)
  695. if (offset + 4 > data.size()) break;
  696. uint32_t optionsLen = 0;
  697. for (int i = 0; i < 4; ++i) {
  698. optionsLen |= static_cast<uint32_t>(data[offset++]) << (i * 8);
  699. }
  700. if (offset + optionsLen > data.size()) break;
  701. std::string optionsJson(reinterpret_cast<const char*>(&data[offset]), optionsLen);
  702. offset += optionsLen;
  703. try {
  704. slice.options = CollectionOptions::fromJson(smartbotic::db::parse_to_nlohmann(optionsJson));
  705. } catch (const nlohmann::json::exception&) {
  706. // Defaults — same fallback as the pre-v1.8 path.
  707. }
  708. // Document section — record its byte range, skip past without parsing
  709. if (offset + 8 > data.size()) break;
  710. for (int i = 0; i < 8; ++i) {
  711. slice.docCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
  712. }
  713. slice.docSectionStart = offset;
  714. for (uint64_t i = 0; i < slice.docCount; ++i) {
  715. if (offset + 4 > data.size()) { offset = data.size(); break; }
  716. uint32_t docLen = 0;
  717. for (int j = 0; j < 4; ++j) {
  718. docLen |= static_cast<uint32_t>(data[offset++]) << (j * 8);
  719. }
  720. if (offset + docLen > data.size()) { offset = data.size(); break; }
  721. offset += docLen;
  722. }
  723. slice.docSectionEnd = offset;
  724. // History section (v2+)
  725. if (snapshotVersion >= 2) {
  726. if (offset + 8 > data.size()) break;
  727. for (int i = 0; i < 8; ++i) {
  728. slice.historyEntryCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
  729. }
  730. slice.historySectionStart = offset;
  731. for (uint64_t h = 0; h < slice.historyEntryCount; ++h) {
  732. if (offset + 2 > data.size()) { offset = data.size(); break; }
  733. uint16_t docIdLen = static_cast<uint16_t>(data[offset]) |
  734. (static_cast<uint16_t>(data[offset + 1]) << 8);
  735. offset += 2;
  736. if (offset + docIdLen > data.size()) { offset = data.size(); break; }
  737. offset += docIdLen;
  738. if (offset + 4 > data.size()) { offset = data.size(); break; }
  739. uint32_t versionCount = 0;
  740. for (int i = 0; i < 4; ++i) {
  741. versionCount |= static_cast<uint32_t>(data[offset++]) << (i * 8);
  742. }
  743. for (uint32_t v = 0; v < versionCount; ++v) {
  744. if (offset + 4 > data.size()) { offset = data.size(); break; }
  745. uint32_t verLen = 0;
  746. for (int i = 0; i < 4; ++i) {
  747. verLen |= static_cast<uint32_t>(data[offset++]) << (i * 8);
  748. }
  749. if (offset + verLen > data.size()) { offset = data.size(); break; }
  750. offset += verLen;
  751. }
  752. }
  753. slice.historySectionEnd = offset;
  754. }
  755. // Vector section (v3+)
  756. if (snapshotVersion >= 3) {
  757. if (offset + 8 > data.size()) break;
  758. for (int i = 0; i < 8; ++i) {
  759. slice.vecCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
  760. }
  761. slice.vecSectionStart = offset;
  762. for (uint64_t v = 0; v < slice.vecCount; ++v) {
  763. if (offset + 2 > data.size()) { offset = data.size(); break; }
  764. uint16_t docIdLen = static_cast<uint16_t>(data[offset]) |
  765. (static_cast<uint16_t>(data[offset + 1]) << 8);
  766. offset += 2;
  767. if (offset + docIdLen > data.size()) { offset = data.size(); break; }
  768. offset += docIdLen;
  769. if (offset + 4 > data.size()) { offset = data.size(); break; }
  770. uint32_t dim = 0;
  771. for (int i = 0; i < 4; ++i) {
  772. dim |= static_cast<uint32_t>(data[offset++]) << (i * 8);
  773. }
  774. size_t byteCount = static_cast<size_t>(dim) * sizeof(float);
  775. if (offset + byteCount > data.size()) { offset = data.size(); break; }
  776. offset += byteCount;
  777. }
  778. slice.vecSectionEnd = offset;
  779. }
  780. slices.push_back(std::move(slice));
  781. }
  782. // ===== Phase 2: create all collections sequentially =====
  783. // Doing this before workers start means each worker only mutates its own
  784. // collection's per-coll mutex, never the global collections map.
  785. for (const auto& slice : slices) {
  786. store.createCollection(slice.name, slice.options);
  787. }
  788. // ===== Phase 3: parallel parse + load per collection =====
  789. // Worker: pop a slice index, parse + load all of its docs / history /
  790. // vectors. JSON parsing is the dominant cost; per-collection isolation
  791. // means no shared-mutex contention except on the lock-free atomics.
  792. const auto loadOneSlice = [&data, &store, snapshotVersion](const CollectionSlice& slice) {
  793. const std::string& collName = slice.name;
  794. // --- Documents ---
  795. size_t off = slice.docSectionStart;
  796. for (uint64_t i = 0; i < slice.docCount && off < slice.docSectionEnd; ++i) {
  797. if (off + 4 > slice.docSectionEnd) break;
  798. uint32_t docLen = 0;
  799. for (int j = 0; j < 4; ++j) {
  800. docLen |= static_cast<uint32_t>(data[off++]) << (j * 8);
  801. }
  802. if (off + docLen > slice.docSectionEnd) break;
  803. std::string docJson(reinterpret_cast<const char*>(&data[off]), docLen);
  804. off += docLen;
  805. try {
  806. Document doc = Document::fromJson(smartbotic::db::parse_to_nlohmann(docJson));
  807. store.loadDocument(collName, doc);
  808. } catch (const nlohmann::json::exception&) {
  809. // Skip invalid document — same tolerance as pre-v1.8.
  810. }
  811. }
  812. // --- History ---
  813. if (snapshotVersion >= 2) {
  814. off = slice.historySectionStart;
  815. for (uint64_t h = 0; h < slice.historyEntryCount && off < slice.historySectionEnd; ++h) {
  816. if (off + 2 > slice.historySectionEnd) break;
  817. uint16_t docIdLen = static_cast<uint16_t>(data[off]) |
  818. (static_cast<uint16_t>(data[off + 1]) << 8);
  819. off += 2;
  820. if (off + docIdLen > slice.historySectionEnd) break;
  821. std::string docId(reinterpret_cast<const char*>(&data[off]), docIdLen);
  822. off += docIdLen;
  823. if (off + 4 > slice.historySectionEnd) break;
  824. uint32_t versionCount = 0;
  825. for (int i = 0; i < 4; ++i) {
  826. versionCount |= static_cast<uint32_t>(data[off++]) << (i * 8);
  827. }
  828. std::deque<DocumentVersion> history;
  829. for (uint32_t v = 0; v < versionCount; ++v) {
  830. if (off + 4 > slice.historySectionEnd) break;
  831. uint32_t verLen = 0;
  832. for (int i = 0; i < 4; ++i) {
  833. verLen |= static_cast<uint32_t>(data[off++]) << (i * 8);
  834. }
  835. if (off + verLen > slice.historySectionEnd) break;
  836. std::string verJson(reinterpret_cast<const char*>(&data[off]), verLen);
  837. off += verLen;
  838. try {
  839. auto ver = DocumentVersion::fromJson(smartbotic::db::parse_to_nlohmann(verJson));
  840. history.push_back(std::move(ver));
  841. } catch (const nlohmann::json::exception&) {
  842. // Skip invalid version entry.
  843. }
  844. }
  845. if (!history.empty()) {
  846. store.loadVersionHistory(collName, docId, std::move(history));
  847. }
  848. }
  849. }
  850. // --- Vectors ---
  851. if (snapshotVersion >= 3) {
  852. off = slice.vecSectionStart;
  853. for (uint64_t v = 0; v < slice.vecCount && off < slice.vecSectionEnd; ++v) {
  854. if (off + 2 > slice.vecSectionEnd) break;
  855. uint16_t docIdLen = static_cast<uint16_t>(data[off]) |
  856. (static_cast<uint16_t>(data[off + 1]) << 8);
  857. off += 2;
  858. if (off + docIdLen > slice.vecSectionEnd) break;
  859. std::string docId(reinterpret_cast<const char*>(&data[off]), docIdLen);
  860. off += docIdLen;
  861. if (off + 4 > slice.vecSectionEnd) break;
  862. uint32_t dim = 0;
  863. for (int i = 0; i < 4; ++i) {
  864. dim |= static_cast<uint32_t>(data[off++]) << (i * 8);
  865. }
  866. size_t byteCount = static_cast<size_t>(dim) * sizeof(float);
  867. if (off + byteCount > slice.vecSectionEnd) break;
  868. std::vector<float> vec(dim);
  869. std::memcpy(vec.data(), &data[off], byteCount);
  870. off += byteCount;
  871. store.loadVector(collName, docId, std::move(vec));
  872. }
  873. }
  874. };
  875. if (slices.size() <= 1) {
  876. // Trivial fast path — single collection, no parallelism overhead.
  877. for (const auto& slice : slices) loadOneSlice(slice);
  878. return;
  879. }
  880. const size_t hwc = std::thread::hardware_concurrency();
  881. const size_t workerCount = std::min(slices.size(), hwc > 0 ? hwc : 4);
  882. std::atomic<size_t> nextSlice{0};
  883. std::vector<std::thread> workers;
  884. workers.reserve(workerCount);
  885. for (size_t w = 0; w < workerCount; ++w) {
  886. workers.emplace_back([&]() {
  887. for (;;) {
  888. size_t i = nextSlice.fetch_add(1, std::memory_order_relaxed);
  889. if (i >= slices.size()) break;
  890. loadOneSlice(slices[i]);
  891. }
  892. });
  893. }
  894. for (auto& t : workers) t.join();
  895. spdlog::info("Snapshot deserialized: {} collections via {} workers",
  896. slices.size(), workerCount);
  897. }
  898. } // namespace smartbotic::database