|
|
@@ -2,6 +2,7 @@
|
|
|
#include "wal.hpp"
|
|
|
|
|
|
#include <algorithm>
|
|
|
+#include <atomic>
|
|
|
#include <cerrno>
|
|
|
#include <chrono>
|
|
|
#include <cstring>
|
|
|
@@ -9,6 +10,7 @@
|
|
|
#include <iomanip>
|
|
|
#include <sstream>
|
|
|
#include <system_error>
|
|
|
+#include <thread>
|
|
|
|
|
|
#include <fcntl.h>
|
|
|
#include <unistd.h>
|
|
|
@@ -728,159 +730,279 @@ std::vector<uint8_t> SnapshotManager::serializeStore(const MemoryStore& store,
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
+namespace {
|
|
|
+
|
|
|
+// v1.8.0 — slice describing where one collection's bytes live in the
|
|
|
+// decompressed snapshot body, used by the two-phase parallel deserializer.
|
|
|
+struct CollectionSlice {
|
|
|
+ std::string name;
|
|
|
+ CollectionOptions options;
|
|
|
+ uint64_t docCount = 0;
|
|
|
+ size_t docSectionStart = 0;
|
|
|
+ size_t docSectionEnd = 0;
|
|
|
+ uint64_t historyEntryCount = 0;
|
|
|
+ size_t historySectionStart = 0;
|
|
|
+ size_t historySectionEnd = 0;
|
|
|
+ uint64_t vecCount = 0;
|
|
|
+ size_t vecSectionStart = 0;
|
|
|
+ size_t vecSectionEnd = 0;
|
|
|
+};
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
void SnapshotManager::deserializeStore(const std::vector<uint8_t>& data, MemoryStore& store,
|
|
|
uint32_t snapshotVersion) const {
|
|
|
+ // v1.8.0 — two-phase parallel deserializer. Before this, a 5 GB snapshot
|
|
|
+ // with many JSON-encoded documents pinned a single core at 100% for tens
|
|
|
+ // of seconds during boot. The serialized format is a concatenation of
|
|
|
+ // independent per-collection blocks, so we:
|
|
|
+ // 1. Walk the body once with pure byte arithmetic (no JSON parsing) to
|
|
|
+ // record each collection's name, options, and the byte ranges that
|
|
|
+ // hold its docs / history / vectors. This is cheap and stays in L2.
|
|
|
+ // 2. Create the collections sequentially so global state (collection
|
|
|
+ // map, options) is consistent before workers start.
|
|
|
+ // 3. Spawn N worker threads (N = hardware_concurrency capped at the
|
|
|
+ // number of collections) that pull work off a shared index counter
|
|
|
+ // and parse + load each collection's payload in isolation. JSON
|
|
|
+ // parsing — the actual hot path — runs in parallel; per-collection
|
|
|
+ // MemoryStore mutexes mean no two workers contend on the same
|
|
|
+ // collection's documents map.
|
|
|
+ //
|
|
|
+ // For workloads with many small/medium collections (e.g. ShadowMan, with
|
|
|
+ // ~30 collections) this drops boot time roughly linearly with cores. For
|
|
|
+ // a single-giant-collection workload there is no within-collection
|
|
|
+ // parallelism yet; that would be a follow-up.
|
|
|
+
|
|
|
+ // ===== Phase 1: scan body byte-by-byte to find collection slices =====
|
|
|
+ std::vector<CollectionSlice> slices;
|
|
|
size_t offset = 0;
|
|
|
-
|
|
|
while (offset < data.size()) {
|
|
|
- // Read collection name
|
|
|
+ CollectionSlice slice;
|
|
|
+
|
|
|
+ // Collection name
|
|
|
if (offset + 2 > data.size()) break;
|
|
|
uint16_t nameLen = static_cast<uint16_t>(data[offset]) |
|
|
|
(static_cast<uint16_t>(data[offset + 1]) << 8);
|
|
|
offset += 2;
|
|
|
-
|
|
|
if (offset + nameLen > data.size()) break;
|
|
|
- std::string collName(reinterpret_cast<const char*>(&data[offset]), nameLen);
|
|
|
+ slice.name.assign(reinterpret_cast<const char*>(&data[offset]), nameLen);
|
|
|
offset += nameLen;
|
|
|
|
|
|
- // Read collection options
|
|
|
+ // Collection options (small JSON — keep parsing here, sequential)
|
|
|
if (offset + 4 > data.size()) break;
|
|
|
uint32_t optionsLen = 0;
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
optionsLen |= static_cast<uint32_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
if (offset + optionsLen > data.size()) break;
|
|
|
std::string optionsJson(reinterpret_cast<const char*>(&data[offset]), optionsLen);
|
|
|
offset += optionsLen;
|
|
|
-
|
|
|
- CollectionOptions options;
|
|
|
try {
|
|
|
- options = CollectionOptions::fromJson(nlohmann::json::parse(optionsJson));
|
|
|
+ slice.options = CollectionOptions::fromJson(nlohmann::json::parse(optionsJson));
|
|
|
} catch (const nlohmann::json::exception&) {
|
|
|
- // Use default options
|
|
|
+ // Defaults — same fallback as the pre-v1.8 path.
|
|
|
}
|
|
|
|
|
|
- // Create collection
|
|
|
- store.createCollection(collName, options);
|
|
|
-
|
|
|
- // Read document count
|
|
|
+ // Document section — record its byte range, skip past without parsing
|
|
|
if (offset + 8 > data.size()) break;
|
|
|
- uint64_t docCount = 0;
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
|
- docCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
+ slice.docCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- // Read each document
|
|
|
- for (uint64_t i = 0; i < docCount; ++i) {
|
|
|
- if (offset + 4 > data.size()) break;
|
|
|
+ slice.docSectionStart = offset;
|
|
|
+ for (uint64_t i = 0; i < slice.docCount; ++i) {
|
|
|
+ if (offset + 4 > data.size()) { offset = data.size(); break; }
|
|
|
uint32_t docLen = 0;
|
|
|
for (int j = 0; j < 4; ++j) {
|
|
|
docLen |= static_cast<uint32_t>(data[offset++]) << (j * 8);
|
|
|
}
|
|
|
-
|
|
|
- if (offset + docLen > data.size()) break;
|
|
|
- std::string docJson(reinterpret_cast<const char*>(&data[offset]), docLen);
|
|
|
+ if (offset + docLen > data.size()) { offset = data.size(); break; }
|
|
|
offset += docLen;
|
|
|
-
|
|
|
- try {
|
|
|
- Document doc = Document::fromJson(nlohmann::json::parse(docJson));
|
|
|
- store.loadDocument(collName, doc);
|
|
|
- } catch (const nlohmann::json::exception&) {
|
|
|
- // Skip invalid document
|
|
|
- }
|
|
|
}
|
|
|
+ slice.docSectionEnd = offset;
|
|
|
|
|
|
- // Read version history (v2+)
|
|
|
+ // History section (v2+)
|
|
|
if (snapshotVersion >= 2) {
|
|
|
if (offset + 8 > data.size()) break;
|
|
|
- uint64_t historyEntryCount = 0;
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
|
- historyEntryCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
+ slice.historyEntryCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- for (uint64_t h = 0; h < historyEntryCount; ++h) {
|
|
|
- // Read doc ID
|
|
|
- if (offset + 2 > data.size()) break;
|
|
|
+ slice.historySectionStart = offset;
|
|
|
+ for (uint64_t h = 0; h < slice.historyEntryCount; ++h) {
|
|
|
+ if (offset + 2 > data.size()) { offset = data.size(); break; }
|
|
|
uint16_t docIdLen = static_cast<uint16_t>(data[offset]) |
|
|
|
(static_cast<uint16_t>(data[offset + 1]) << 8);
|
|
|
offset += 2;
|
|
|
-
|
|
|
- if (offset + docIdLen > data.size()) break;
|
|
|
- std::string docId(reinterpret_cast<const char*>(&data[offset]), docIdLen);
|
|
|
+ if (offset + docIdLen > data.size()) { offset = data.size(); break; }
|
|
|
offset += docIdLen;
|
|
|
-
|
|
|
- // Read version count
|
|
|
- if (offset + 4 > data.size()) break;
|
|
|
+ if (offset + 4 > data.size()) { offset = data.size(); break; }
|
|
|
uint32_t versionCount = 0;
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
versionCount |= static_cast<uint32_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- // Read each version into a deque
|
|
|
- std::deque<DocumentVersion> history;
|
|
|
for (uint32_t v = 0; v < versionCount; ++v) {
|
|
|
- if (offset + 4 > data.size()) break;
|
|
|
+ if (offset + 4 > data.size()) { offset = data.size(); break; }
|
|
|
uint32_t verLen = 0;
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
verLen |= static_cast<uint32_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- if (offset + verLen > data.size()) break;
|
|
|
- std::string verJson(reinterpret_cast<const char*>(&data[offset]), verLen);
|
|
|
+ if (offset + verLen > data.size()) { offset = data.size(); break; }
|
|
|
offset += verLen;
|
|
|
-
|
|
|
- try {
|
|
|
- auto ver = DocumentVersion::fromJson(nlohmann::json::parse(verJson));
|
|
|
- history.push_back(std::move(ver));
|
|
|
- } catch (const nlohmann::json::exception&) {
|
|
|
- // Skip invalid version entry
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!history.empty()) {
|
|
|
- store.loadVersionHistory(collName, docId, std::move(history));
|
|
|
}
|
|
|
}
|
|
|
+ slice.historySectionEnd = offset;
|
|
|
}
|
|
|
|
|
|
- // Read vector data (v3+)
|
|
|
+ // Vector section (v3+)
|
|
|
if (snapshotVersion >= 3) {
|
|
|
if (offset + 8 > data.size()) break;
|
|
|
- uint64_t vecCount = 0;
|
|
|
for (int i = 0; i < 8; ++i) {
|
|
|
- vecCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
+ slice.vecCount |= static_cast<uint64_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- for (uint64_t v = 0; v < vecCount; ++v) {
|
|
|
- // Read doc ID
|
|
|
- if (offset + 2 > data.size()) break;
|
|
|
+ slice.vecSectionStart = offset;
|
|
|
+ for (uint64_t v = 0; v < slice.vecCount; ++v) {
|
|
|
+ if (offset + 2 > data.size()) { offset = data.size(); break; }
|
|
|
uint16_t docIdLen = static_cast<uint16_t>(data[offset]) |
|
|
|
(static_cast<uint16_t>(data[offset + 1]) << 8);
|
|
|
offset += 2;
|
|
|
-
|
|
|
- if (offset + docIdLen > data.size()) break;
|
|
|
- std::string docId(reinterpret_cast<const char*>(&data[offset]), docIdLen);
|
|
|
+ if (offset + docIdLen > data.size()) { offset = data.size(); break; }
|
|
|
offset += docIdLen;
|
|
|
-
|
|
|
- // Read vector dimension
|
|
|
- if (offset + 4 > data.size()) break;
|
|
|
+ if (offset + 4 > data.size()) { offset = data.size(); break; }
|
|
|
uint32_t dim = 0;
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
dim |= static_cast<uint32_t>(data[offset++]) << (i * 8);
|
|
|
}
|
|
|
-
|
|
|
- // Read raw float data
|
|
|
size_t byteCount = static_cast<size_t>(dim) * sizeof(float);
|
|
|
- if (offset + byteCount > data.size()) break;
|
|
|
- std::vector<float> vec(dim);
|
|
|
- std::memcpy(vec.data(), &data[offset], byteCount);
|
|
|
+ if (offset + byteCount > data.size()) { offset = data.size(); break; }
|
|
|
offset += byteCount;
|
|
|
+ }
|
|
|
+ slice.vecSectionEnd = offset;
|
|
|
+ }
|
|
|
|
|
|
+ slices.push_back(std::move(slice));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Phase 2: create all collections sequentially =====
|
|
|
+ // Doing this before workers start means each worker only mutates its own
|
|
|
+ // collection's per-coll mutex, never the global collections map.
|
|
|
+ for (const auto& slice : slices) {
|
|
|
+ store.createCollection(slice.name, slice.options);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Phase 3: parallel parse + load per collection =====
|
|
|
+ // Worker: pop a slice index, parse + load all of its docs / history /
|
|
|
+ // vectors. JSON parsing is the dominant cost; per-collection isolation
|
|
|
+ // means no shared-mutex contention except on the lock-free atomics.
|
|
|
+ const auto loadOneSlice = [&data, &store, snapshotVersion](const CollectionSlice& slice) {
|
|
|
+ const std::string& collName = slice.name;
|
|
|
+
|
|
|
+ // --- Documents ---
|
|
|
+ size_t off = slice.docSectionStart;
|
|
|
+ for (uint64_t i = 0; i < slice.docCount && off < slice.docSectionEnd; ++i) {
|
|
|
+ if (off + 4 > slice.docSectionEnd) break;
|
|
|
+ uint32_t docLen = 0;
|
|
|
+ for (int j = 0; j < 4; ++j) {
|
|
|
+ docLen |= static_cast<uint32_t>(data[off++]) << (j * 8);
|
|
|
+ }
|
|
|
+ if (off + docLen > slice.docSectionEnd) break;
|
|
|
+ std::string docJson(reinterpret_cast<const char*>(&data[off]), docLen);
|
|
|
+ off += docLen;
|
|
|
+ try {
|
|
|
+ Document doc = Document::fromJson(nlohmann::json::parse(docJson));
|
|
|
+ store.loadDocument(collName, doc);
|
|
|
+ } catch (const nlohmann::json::exception&) {
|
|
|
+ // Skip invalid document — same tolerance as pre-v1.8.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- History ---
|
|
|
+ if (snapshotVersion >= 2) {
|
|
|
+ off = slice.historySectionStart;
|
|
|
+ for (uint64_t h = 0; h < slice.historyEntryCount && off < slice.historySectionEnd; ++h) {
|
|
|
+ if (off + 2 > slice.historySectionEnd) break;
|
|
|
+ uint16_t docIdLen = static_cast<uint16_t>(data[off]) |
|
|
|
+ (static_cast<uint16_t>(data[off + 1]) << 8);
|
|
|
+ off += 2;
|
|
|
+ if (off + docIdLen > slice.historySectionEnd) break;
|
|
|
+ std::string docId(reinterpret_cast<const char*>(&data[off]), docIdLen);
|
|
|
+ off += docIdLen;
|
|
|
+ if (off + 4 > slice.historySectionEnd) break;
|
|
|
+ uint32_t versionCount = 0;
|
|
|
+ for (int i = 0; i < 4; ++i) {
|
|
|
+ versionCount |= static_cast<uint32_t>(data[off++]) << (i * 8);
|
|
|
+ }
|
|
|
+ std::deque<DocumentVersion> history;
|
|
|
+ for (uint32_t v = 0; v < versionCount; ++v) {
|
|
|
+ if (off + 4 > slice.historySectionEnd) break;
|
|
|
+ uint32_t verLen = 0;
|
|
|
+ for (int i = 0; i < 4; ++i) {
|
|
|
+ verLen |= static_cast<uint32_t>(data[off++]) << (i * 8);
|
|
|
+ }
|
|
|
+ if (off + verLen > slice.historySectionEnd) break;
|
|
|
+ std::string verJson(reinterpret_cast<const char*>(&data[off]), verLen);
|
|
|
+ off += verLen;
|
|
|
+ try {
|
|
|
+ auto ver = DocumentVersion::fromJson(nlohmann::json::parse(verJson));
|
|
|
+ history.push_back(std::move(ver));
|
|
|
+ } catch (const nlohmann::json::exception&) {
|
|
|
+ // Skip invalid version entry.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!history.empty()) {
|
|
|
+ store.loadVersionHistory(collName, docId, std::move(history));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Vectors ---
|
|
|
+ if (snapshotVersion >= 3) {
|
|
|
+ off = slice.vecSectionStart;
|
|
|
+ for (uint64_t v = 0; v < slice.vecCount && off < slice.vecSectionEnd; ++v) {
|
|
|
+ if (off + 2 > slice.vecSectionEnd) break;
|
|
|
+ uint16_t docIdLen = static_cast<uint16_t>(data[off]) |
|
|
|
+ (static_cast<uint16_t>(data[off + 1]) << 8);
|
|
|
+ off += 2;
|
|
|
+ if (off + docIdLen > slice.vecSectionEnd) break;
|
|
|
+ std::string docId(reinterpret_cast<const char*>(&data[off]), docIdLen);
|
|
|
+ off += docIdLen;
|
|
|
+ if (off + 4 > slice.vecSectionEnd) break;
|
|
|
+ uint32_t dim = 0;
|
|
|
+ for (int i = 0; i < 4; ++i) {
|
|
|
+ dim |= static_cast<uint32_t>(data[off++]) << (i * 8);
|
|
|
+ }
|
|
|
+ size_t byteCount = static_cast<size_t>(dim) * sizeof(float);
|
|
|
+ if (off + byteCount > slice.vecSectionEnd) break;
|
|
|
+ std::vector<float> vec(dim);
|
|
|
+ std::memcpy(vec.data(), &data[off], byteCount);
|
|
|
+ off += byteCount;
|
|
|
store.loadVector(collName, docId, std::move(vec));
|
|
|
}
|
|
|
}
|
|
|
+ };
|
|
|
+
|
|
|
+ if (slices.size() <= 1) {
|
|
|
+ // Trivial fast path — single collection, no parallelism overhead.
|
|
|
+ for (const auto& slice : slices) loadOneSlice(slice);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const size_t hwc = std::thread::hardware_concurrency();
|
|
|
+ const size_t workerCount = std::min(slices.size(), hwc > 0 ? hwc : 4);
|
|
|
+ std::atomic<size_t> nextSlice{0};
|
|
|
+ std::vector<std::thread> workers;
|
|
|
+ workers.reserve(workerCount);
|
|
|
+ for (size_t w = 0; w < workerCount; ++w) {
|
|
|
+ workers.emplace_back([&]() {
|
|
|
+ for (;;) {
|
|
|
+ size_t i = nextSlice.fetch_add(1, std::memory_order_relaxed);
|
|
|
+ if (i >= slices.size()) break;
|
|
|
+ loadOneSlice(slices[i]);
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
+ for (auto& t : workers) t.join();
|
|
|
+
|
|
|
+ spdlog::info("Snapshot deserialized: {} collections via {} workers",
|
|
|
+ slices.size(), workerCount);
|
|
|
}
|
|
|
|
|
|
} // namespace smartbotic::database
|