database_service.cpp 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. #include "database_service.hpp"
  2. #include "json_parse.hpp"
  3. #include "storage/document_store.hpp"
  4. #include "storage/document_store_lmdb.hpp"
  5. #include "storage/dual_write_mirror.hpp"
  6. #include "storage/lmdb_env.hpp"
  7. #include "storage/migrate_v1_to_v2.hpp"
  8. #include <grpcpp/grpcpp.h>
  9. #include <grpcpp/resource_quota.h>
  10. #include <spdlog/spdlog.h>
  11. #include <fstream>
  12. #ifdef HAVE_SYSTEMD
  13. #include <systemd/sd-daemon.h>
  14. #endif
  15. #if defined(__GLIBC__) && !defined(__APPLE__)
  16. #include <malloc.h>
  17. #endif
  18. namespace smartbotic::database {
  19. DatabaseService::DatabaseService(Config config)
  20. : config_(std::move(config))
  21. {
  22. }
  23. DatabaseService::~DatabaseService() {
  24. stop();
  25. }
  26. std::string DatabaseService::readOnlyReason() const {
  27. std::lock_guard<std::mutex> lock(reason_mutex_);
  28. return read_only_reason_;
  29. }
  30. void DatabaseService::setReadOnly(bool value, const std::string& reason) {
  31. {
  32. std::lock_guard<std::mutex> lock(reason_mutex_);
  33. read_only_reason_ = value ? reason : "";
  34. }
  35. bool prev = read_only_.exchange(value, std::memory_order_acq_rel);
  36. if (prev != value) {
  37. if (value) {
  38. spdlog::error("Database entered READ-ONLY mode: {}", reason);
  39. } else {
  40. spdlog::info("Database unlocked -- writes accepted");
  41. }
  42. }
  43. }
  44. bool DatabaseService::initialize() {
  45. spdlog::info("Initializing database service (node: {})", config_.nodeId);
  46. try {
  47. // Create data directory if needed
  48. std::error_code ec;
  49. std::filesystem::create_directories(config_.dataDirectory, ec);
  50. if (ec) {
  51. spdlog::error("Failed to create data directory: {}", ec.message());
  52. return false;
  53. }
  54. setupComponents();
  55. // Initialize encryption
  56. if (config_.encryptionEnabled) {
  57. if (!encryption_->initialize()) {
  58. spdlog::error("Failed to initialize encryption");
  59. return false;
  60. }
  61. }
  62. // Recover from persistence
  63. recovery_outcome_ = persistence_->recover(*store_);
  64. #if defined(__GLIBC__) && !defined(__APPLE__)
  65. // v1.9.3 — release freelist pages accumulated during recovery. v1.9.1
  66. // added trim at end of loadSnapshot, but the WAL replay phase that
  67. // runs afterward (`persistence_->recover`'s `replayWal` step) also
  68. // burns through GBs of small Document JSON allocations whose pages
  69. // sit on the per-thread freelist with no subsequent allocation to
  70. // shake them loose. On Zoe (BUG-memory-leak-zoe.md update 15:05)
  71. // this was 5.7 GB at 23 min uptime — fully reclaimable via
  72. // malloc_trim, just nothing called it. Paired with the periodic
  73. // every-5-minute trim in MemoryStore::logMemoryCheck, so any
  74. // post-boot bloat that escapes this trim gets cleaned up shortly.
  75. ::malloc_trim(0);
  76. #endif
  77. if (recovery_outcome_.isFailure()) {
  78. const std::string modeStr =
  79. recoveryModeToString(config_.persistenceConfig.recoveryMode);
  80. spdlog::error("");
  81. spdlog::error("+------------------------------------------------------------------+");
  82. spdlog::error("| RECOVERY REFUSED |");
  83. spdlog::error("| |");
  84. spdlog::error("| Mode: {}", modeStr);
  85. spdlog::error("| Expected snapshot: {}", recovery_outcome_.expectedSnapshot.string());
  86. spdlog::error("| Reason: {}", recovery_outcome_.failureReason);
  87. spdlog::error("| |");
  88. spdlog::error("| Snapshots available: {}", recovery_outcome_.snapshotsAvailable);
  89. spdlog::error("| Snapshots attempted: {}", recovery_outcome_.snapshotsAttempted);
  90. spdlog::error("| |");
  91. spdlog::error("| To escalate, restart with one of: |");
  92. spdlog::error("| --recovery-mode=snapshot_fallback Try older snapshots |");
  93. spdlog::error("| --recovery-mode=wal_only Replay WAL only (slow) |");
  94. spdlog::error("| --recovery-mode=best_effort Try all of the above |");
  95. spdlog::error("| --recovery-mode=force_empty Start empty (LAST RESORT) |");
  96. spdlog::error("| |");
  97. spdlog::error("| Or set in config.json: \"recovery\": {{ \"mode\": \"<mode>\" }} |");
  98. spdlog::error("| |");
  99. spdlog::error("| PRESERVE /var/lib/smartbotic-database/ BEFORE ESCALATING. |");
  100. spdlog::error("+------------------------------------------------------------------+");
  101. throw std::runtime_error("recovery failed; refusing to start");
  102. }
  103. // Auto-readonly mode on non-trivial recovery
  104. if (recovery_outcome_.isNonTrivial() && !force_readwrite_) {
  105. std::string reason;
  106. switch (recovery_outcome_.kind) {
  107. case RecoveryOutcome::Kind::SnapshotFellBack:
  108. reason = "fell back to snapshot " +
  109. recovery_outcome_.snapshotUsed.filename().string() +
  110. " because " +
  111. recovery_outcome_.expectedSnapshot.filename().string() +
  112. " failed: " + recovery_outcome_.failureReason;
  113. break;
  114. case RecoveryOutcome::Kind::WalOnlyReplay:
  115. reason = "WAL-only replay, no snapshot loaded (" +
  116. std::to_string(recovery_outcome_.walEntriesReplayed) +
  117. " entries)";
  118. break;
  119. case RecoveryOutcome::Kind::ForcedEmpty:
  120. reason = "forced empty by operator (--recovery-mode=force_empty)";
  121. break;
  122. default:
  123. reason = "non-trivial recovery";
  124. break;
  125. }
  126. reason += ". Run `smartbotic-db-cli unlock` to accept this state, "
  127. "or restart with --force-readwrite to bypass this check.";
  128. setReadOnly(true, reason);
  129. spdlog::error("");
  130. spdlog::error("+------------------------------------------------------------------+");
  131. spdlog::error("| [ERROR] Database booted in READ-ONLY mode after non-trivial |");
  132. spdlog::error("| recovery. |");
  133. spdlog::error("| |");
  134. spdlog::error("| Reason: {}", reason);
  135. spdlog::error("| |");
  136. spdlog::error("| Writes will be REJECTED until you acknowledge this state: |");
  137. spdlog::error("| smartbotic-db-cli unlock # live, no restart |");
  138. spdlog::error("| smartbotic-database --force-readwrite # on next restart |");
  139. spdlog::error("+------------------------------------------------------------------+");
  140. }
  141. // Set replication sequence after recovery (WAL sequence is now known)
  142. replication_->setSequence(persistence_->currentWalSequence());
  143. // v1.8.0 — start the persistence manager before loadFromStore /
  144. // migrations so any system-collection writes those phases produce
  145. // (view docs, _collection_meta entries, _migrations recordings)
  146. // reach the WAL like normal mutations. Pre-v1.8, persistence_ was
  147. // started later in start(), which silently dropped those writes
  148. // (running_=false → logInsert no-op). That meant migrated views
  149. // only "survived" because the runner re-created them every boot,
  150. // and a manually-created view that landed during the racy startup
  151. // window (before full readiness) could be lost.
  152. if (!persistence_->start()) {
  153. spdlog::error("Failed to start persistence manager before migrations");
  154. return false;
  155. }
  156. // Load view definitions from the _views system collection (which is now
  157. // populated by the persistence recovery above).
  158. view_manager_->loadFromStore();
  159. // Load per-collection configs from the _collection_meta system collection.
  160. // Must happen AFTER persistence recovery and BEFORE migrations so any
  161. // migration-created documents are stamped with the correct precision.
  162. config_manager_->loadFromStore();
  163. // Run migrations if enabled
  164. if (config_.migrations.enabled && !config_.migrations.directory.empty()) {
  165. if (!runMigrations()) {
  166. if (config_.migrations.failOnError) {
  167. spdlog::error("Failed to run migrations");
  168. return false;
  169. }
  170. spdlog::warn("Some migrations failed, continuing anyway");
  171. }
  172. }
  173. // v2.0 Stage 4 — synchronous backfill into doc_store_ before
  174. // serving. Without this, only post-boot writes land in LMDB and
  175. // the read-flip downstream would see an empty mirror.
  176. backfillIntoDocStore();
  177. spdlog::info("Database service initialized successfully");
  178. return true;
  179. } catch (const std::exception& e) {
  180. spdlog::error("Failed to initialize database service: {}", e.what());
  181. return false;
  182. }
  183. }
  184. bool DatabaseService::runMigrations() {
  185. if (!config_.migrations.enabled) {
  186. return true;
  187. }
  188. MigrationRunner::Config migrationConfig;
  189. migrationConfig.directory = config_.migrations.directory;
  190. migrationConfig.autoApply = config_.migrations.autoApply;
  191. migrationConfig.failOnError = config_.migrations.failOnError;
  192. migrationRunner_ = std::make_unique<MigrationRunner>(*store_, *view_manager_, migrationConfig);
  193. return migrationRunner_->runMigrations();
  194. }
  195. bool DatabaseService::backfillIntoDocStore() {
  196. if (!doc_store_ || !env_) {
  197. // Env open failed earlier; nothing to backfill into. mirror_healthy_
  198. // stays true (default) — there's no mirror to be unhealthy.
  199. return true;
  200. }
  201. // Skip when migrate_v1_to_v2 already populated the env. That tool
  202. // writes _meta.schema_version=2 atomically on success.
  203. try {
  204. if (smartbotic::db::storage::migration_complete(*env_)) {
  205. spdlog::info("v2.0 backfill: env already migrated (schema_version=2), skipping");
  206. return true;
  207. }
  208. } catch (const std::exception& e) {
  209. spdlog::warn("v2.0 backfill: migration_complete probe failed: {} — "
  210. "treating env as fresh and proceeding with backfill",
  211. e.what());
  212. }
  213. const auto collections = store_->listCollections();
  214. uint64_t total_docs = 0;
  215. uint64_t total_failures = 0;
  216. auto t0 = std::chrono::steady_clock::now();
  217. for (const auto& collection : collections) {
  218. if (collection.empty() || collection[0] == '_') continue;
  219. const auto docs = store_->getAllDocuments(collection);
  220. for (const auto& doc : docs) {
  221. std::optional<Document> opt_doc(doc);
  222. const uint64_t drift_before = mirror_drift_count_.load(std::memory_order_relaxed);
  223. smartbotic::db::storage::applyDualWriteMirror(
  224. doc_store_.get(), mirror_healthy_, mirror_drift_count_,
  225. collection, doc.id, opt_doc, EventType::INSERT);
  226. if (mirror_drift_count_.load(std::memory_order_relaxed) > drift_before) {
  227. ++total_failures;
  228. }
  229. ++total_docs;
  230. if (total_docs % 10000 == 0) {
  231. sd_notify(0, "EXTEND_TIMEOUT_USEC=600000000");
  232. const std::string status = "STATUS=v2.0 backfill: " +
  233. std::to_string(total_docs) + " docs mirrored";
  234. sd_notify(0, status.c_str());
  235. spdlog::info("v2.0 backfill: {} docs mirrored ({} failures so far)",
  236. total_docs, total_failures);
  237. }
  238. }
  239. }
  240. const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
  241. std::chrono::steady_clock::now() - t0).count();
  242. if (total_failures == 0) {
  243. // v2.2.1 — stamp _meta.schema_version=2 so subsequent boots
  244. // short-circuit at migration_complete() and skip the re-backfill.
  245. // Pre-2.2.1 the marker was only set by the offline migrate_v1_to_v2
  246. // tool, so every restart re-walked the entire MemoryStore.
  247. try {
  248. smartbotic::db::storage::mark_migration_complete(*env_);
  249. spdlog::info("v2.0 backfill: complete — {} docs across {} collections "
  250. "in {} ms (schema_version=2 marker set)",
  251. total_docs, collections.size(), elapsed);
  252. } catch (const std::exception& e) {
  253. spdlog::warn("v2.0 backfill: completed {} docs in {} ms but the "
  254. "schema_version marker write failed: {} — next boot "
  255. "will re-backfill",
  256. total_docs, elapsed, e.what());
  257. }
  258. } else {
  259. spdlog::error("v2.0 backfill: completed with {} failures out of {} docs "
  260. "in {} ms — mirror_healthy_=false, reads must NOT flip to LMDB "
  261. "and schema_version marker NOT set (next boot retries)",
  262. total_failures, total_docs, elapsed);
  263. }
  264. return true;
  265. }
  266. void DatabaseService::start() {
  267. if (running_.exchange(true)) {
  268. return;
  269. }
  270. spdlog::info("Starting database service on {}:{}", config_.bindAddress, config_.rpcPort);
  271. // Start components
  272. store_->start();
  273. persistence_->start();
  274. events_->start();
  275. files_->start();
  276. if (config_.replicationEnabled) {
  277. replication_->start();
  278. // Set initial local collections for discovery
  279. replication_->setLocalCollections(store_->listCollections());
  280. }
  281. // Start gRPC server
  282. startGrpcServer();
  283. spdlog::info("Database service started");
  284. }
  285. void DatabaseService::stop() {
  286. if (!running_.exchange(false)) {
  287. return;
  288. }
  289. spdlog::info("Stopping database service...");
  290. // Stop gRPC server first
  291. stopGrpcServer();
  292. // Stop components in reverse order
  293. if (replication_) {
  294. replication_->stop();
  295. }
  296. if (files_) {
  297. files_->stop();
  298. }
  299. if (events_) {
  300. events_->stop();
  301. }
  302. if (persistence_ && store_) {
  303. // v1.8.0 — take a final snapshot on graceful shutdown so the next
  304. // boot's recovery is "trivial" (snapshot-loaded) instead of "WAL-only
  305. // replay" which trips auto-readonly mode. This makes ordinary
  306. // restarts pass through cleanly without needing
  307. // `smartbotic-db-cli unlock`.
  308. //
  309. // v1.8.2 — extend systemd's stop watchdog before doing it. On Zoe
  310. // (1.85M docs / ~5 GB tracked) the serialize takes ~70 s and the
  311. // default TimeoutStopSec=30s SIGKILLed the process mid-write, peak
  312. // RSS hit 11 GB (live state + uncompressed serialize buffer), and
  313. // the new snapshot never made it to disk anyway. EXTEND_TIMEOUT_USEC
  314. // tells systemd we're working — same protocol the recovery path
  315. // uses during startup. Paired with TimeoutStopSec=300 in the unit
  316. // file as the static cap.
  317. #ifdef HAVE_SYSTEMD
  318. sd_notify(0, "EXTEND_TIMEOUT_USEC=600000000");
  319. sd_notify(0, "STATUS=Writing final snapshot");
  320. #endif
  321. try {
  322. persistence_->forceSnapshot(*store_);
  323. spdlog::info("Final snapshot taken on shutdown");
  324. } catch (const std::exception& e) {
  325. spdlog::warn("Final snapshot on shutdown failed: {} — recovery on next "
  326. "boot may be WAL-only and trip auto-readonly mode", e.what());
  327. }
  328. }
  329. if (persistence_) {
  330. persistence_->stop();
  331. }
  332. if (store_) {
  333. store_->stop();
  334. }
  335. spdlog::info("Database service stopped");
  336. }
  337. void DatabaseService::wait() {
  338. if (serverThread_.joinable()) {
  339. serverThread_.join();
  340. }
  341. }
  342. void DatabaseService::signalStop() {
  343. stopRequested_ = true;
  344. stop();
  345. }
  346. nlohmann::json DatabaseService::getStats() const {
  347. nlohmann::json stats;
  348. if (store_) {
  349. auto storeStats = store_->getStats();
  350. stats["documents"] = storeStats.totalDocuments;
  351. stats["collections"] = storeStats.totalCollections;
  352. stats["memory_bytes"] = storeStats.estimatedMemoryBytes;
  353. stats["inserts"] = storeStats.insertCount;
  354. stats["updates"] = storeStats.updateCount;
  355. stats["deletes"] = storeStats.deleteCount;
  356. stats["queries"] = storeStats.queryCount;
  357. }
  358. if (persistence_) {
  359. auto persistStats = persistence_->getStats();
  360. stats["wal_sequence"] = persistStats.walSequence;
  361. stats["wal_size_bytes"] = persistStats.walSizeBytes;
  362. stats["snapshot_count"] = persistStats.snapshotCount;
  363. stats["last_snapshot_sequence"] = persistStats.lastSnapshotSequence;
  364. }
  365. if (events_) {
  366. stats["subscriptions"] = events_->subscriptionCount();
  367. }
  368. return stats;
  369. }
  370. DatabaseService::Config DatabaseService::loadConfig(const std::filesystem::path& configPath) {
  371. std::ifstream file(configPath);
  372. if (!file) {
  373. throw std::runtime_error("Failed to open config file: " + configPath.string());
  374. }
  375. nlohmann::json json;
  376. file >> json;
  377. return parseConfig(json);
  378. }
  379. DatabaseService::Config DatabaseService::parseConfig(const nlohmann::json& json) {
  380. Config config;
  381. // Check for both "storage" and "database" keys for backward compatibility
  382. const nlohmann::json* dbConfig = nullptr;
  383. if (json.contains("database")) {
  384. dbConfig = &json["database"];
  385. } else if (json.contains("storage")) {
  386. dbConfig = &json["storage"];
  387. }
  388. if (dbConfig) {
  389. const auto& db = *dbConfig;
  390. config.nodeId = db.value("node_id", config.nodeId);
  391. config.bindAddress = db.value("bind_address", config.bindAddress);
  392. config.rpcPort = db.value("rpc_port", config.rpcPort);
  393. // Expand environment variables in data directory
  394. std::string dataDir = db.value("data_directory", "");
  395. if (dataDir.find("${HOME}") != std::string::npos) {
  396. const char* home = std::getenv("HOME");
  397. if (home) {
  398. size_t pos = dataDir.find("${HOME}");
  399. dataDir.replace(pos, 7, home);
  400. }
  401. }
  402. config.dataDirectory = dataDir;
  403. // Migrations settings
  404. if (db.contains("migrations")) {
  405. auto& migrations = db["migrations"];
  406. config.migrations.enabled = migrations.value("enabled", config.migrations.enabled);
  407. config.migrations.autoApply = migrations.value("auto_apply", config.migrations.autoApply);
  408. config.migrations.failOnError = migrations.value("fail_on_error", config.migrations.failOnError);
  409. std::string migDir = migrations.value("directory", "");
  410. if (migDir.find("${HOME}") != std::string::npos) {
  411. const char* home = std::getenv("HOME");
  412. if (home) {
  413. size_t pos = migDir.find("${HOME}");
  414. migDir.replace(pos, 7, home);
  415. }
  416. }
  417. config.migrations.directory = migDir;
  418. }
  419. // Memory eviction settings
  420. if (db.contains("memory")) {
  421. auto& memory = db["memory"];
  422. // v2.0 Stage 8 — deprecation log. These knobs control MemoryStore
  423. // eviction, which is still active for the MemoryStore-side mirror
  424. // but does NOT bound RSS in v2.0 (LMDB mmap is the dominant RSS
  425. // contributor). The substrate-level equivalent is the LMDB env
  426. // mapsize and OS page cache. v2.1 will rename `max_memory_mb` to
  427. // `buffer_pool_size_mb` per the Phase C plan.
  428. for (const char* deprecated : {"max_memory_mb",
  429. "eviction_threshold_percent",
  430. "eviction_target_percent",
  431. "eviction_check_interval_ms",
  432. "eviction_chunk_size",
  433. "eviction_chunk_pause_ms",
  434. "max_eviction_passes_per_trigger",
  435. "hot_write_floor_ms",
  436. "memory_priority"}) {
  437. if (memory.contains(deprecated)) {
  438. spdlog::warn("v2.0 deprecation: storage.memory.{} is deprecated "
  439. "and will be removed in v2.1. v2.0 RSS is bounded "
  440. "by the LMDB env mapsize + OS page cache, not by "
  441. "MemoryStore eviction. Setting still applied to "
  442. "the MemoryStore mirror for back-compat.",
  443. deprecated);
  444. }
  445. }
  446. config.maxMemoryMb = memory.value("max_memory_mb", config.maxMemoryMb);
  447. config.evictionThresholdPercent = memory.value("eviction_threshold_percent", config.evictionThresholdPercent);
  448. config.evictionTargetPercent = memory.value("eviction_target_percent", config.evictionTargetPercent);
  449. config.evictionCheckIntervalMs = memory.value("eviction_check_interval_ms", config.evictionCheckIntervalMs);
  450. // NEW v1.7.0 eviction tuning (schema-only in T2; runtime use lands in T3/T4)
  451. config.evictionChunkSize = memory.value("eviction_chunk_size", config.evictionChunkSize);
  452. config.evictionChunkPauseMs = memory.value("eviction_chunk_pause_ms", config.evictionChunkPauseMs);
  453. config.maxEvictionPassesPerTrigger = memory.value("max_eviction_passes_per_trigger", config.maxEvictionPassesPerTrigger);
  454. config.hotWriteFloorMs = memory.value("hot_write_floor_ms", config.hotWriteFloorMs);
  455. config.memorySoftPercent = memory.value("memory_soft_percent", config.memorySoftPercent);
  456. config.memoryHardPercent = memory.value("memory_hard_percent", config.memoryHardPercent);
  457. config.memoryEmergencyPercent = memory.value("memory_emergency_percent", config.memoryEmergencyPercent);
  458. // v1.7.0 T10 — eviction burst event threshold
  459. config.evictionBurstThreshold = memory.value("eviction_burst_threshold", config.evictionBurstThreshold);
  460. }
  461. // Persistence settings
  462. if (db.contains("persistence")) {
  463. auto& persistence = db["persistence"];
  464. config.walSyncIntervalMs = persistence.value("wal_sync_interval_ms", config.walSyncIntervalMs);
  465. config.snapshotIntervalSec = persistence.value("snapshot_interval_sec", config.snapshotIntervalSec);
  466. config.compressionEnabled = persistence.value("compression", "lz4") != "none";
  467. // Snapshot durability (NEW in v1.6.1)
  468. if (persistence.contains("snapshots")) {
  469. const auto& snap = persistence["snapshots"];
  470. config.persistenceConfig.validateAfterWrite = snap.value("validate_after_write", config.persistenceConfig.validateAfterWrite);
  471. config.persistenceConfig.cleanupOnlyIfVerified = snap.value("cleanup_only_if_verified", config.persistenceConfig.cleanupOnlyIfVerified);
  472. }
  473. // Recovery (NEW in v1.6.1)
  474. if (persistence.contains("recovery")) {
  475. const auto& rec = persistence["recovery"];
  476. std::string modeStr = rec.value("mode", std::string("normal"));
  477. try {
  478. config.persistenceConfig.recoveryMode = recoveryModeFromString(modeStr);
  479. } catch (const std::exception& e) {
  480. spdlog::warn("Invalid recovery.mode '{}', defaulting to 'normal': {}",
  481. modeStr, e.what());
  482. config.persistenceConfig.recoveryMode = RecoveryMode::Normal;
  483. }
  484. config.persistenceConfig.autoEscalate = rec.value("auto_escalate", config.persistenceConfig.autoEscalate);
  485. config.persistenceConfig.allowEmptyOnFreshInstall = rec.value("allow_empty_on_fresh_install", config.persistenceConfig.allowEmptyOnFreshInstall);
  486. }
  487. }
  488. // File settings
  489. if (db.contains("files")) {
  490. auto& files = db["files"];
  491. config.maxFileSizeMb = files.value("max_file_size_mb", config.maxFileSizeMb);
  492. config.fileCleanupIntervalSec = files.value("cleanup_orphans_interval_sec", config.fileCleanupIntervalSec);
  493. if (files.contains("allowed_types")) {
  494. for (const auto& type : files["allowed_types"]) {
  495. config.allowedFileTypes.push_back(type.get<std::string>());
  496. }
  497. }
  498. }
  499. // Encryption settings
  500. if (db.contains("encryption")) {
  501. auto& encryption = db["encryption"];
  502. config.encryptionEnabled = encryption.value("enabled", config.encryptionEnabled);
  503. config.autoGenerateKey = encryption.value("auto_generate_key", config.autoGenerateKey);
  504. std::string keyFile = encryption.value("key_file", "");
  505. if (keyFile.find("${HOME}") != std::string::npos) {
  506. const char* home = std::getenv("HOME");
  507. if (home) {
  508. size_t pos = keyFile.find("${HOME}");
  509. keyFile.replace(pos, 7, home);
  510. }
  511. }
  512. config.keyFilePath = keyFile;
  513. }
  514. // Replication settings
  515. if (db.contains("replication")) {
  516. auto& replication = db["replication"];
  517. config.replicationEnabled = replication.value("enabled", config.replicationEnabled);
  518. config.conflictResolution = replication.value("conflict_resolution", config.conflictResolution);
  519. if (replication.contains("peers")) {
  520. for (const auto& peer : replication["peers"]) {
  521. config.peerAddresses.push_back(peer.get<std::string>());
  522. }
  523. }
  524. }
  525. // gRPC settings (v1.6.2 — concurrency cap + configurable message sizes)
  526. if (db.contains("grpc")) {
  527. const auto& grpc = db["grpc"];
  528. config.grpc.maxReceiveMessageSizeMb =
  529. grpc.value("max_receive_message_size_mb", config.grpc.maxReceiveMessageSizeMb);
  530. config.grpc.maxSendMessageSizeMb =
  531. grpc.value("max_send_message_size_mb", config.grpc.maxSendMessageSizeMb);
  532. config.grpc.resourceQuotaMemoryMb =
  533. grpc.value("resource_quota_memory_mb", config.grpc.resourceQuotaMemoryMb);
  534. config.grpc.maxConcurrentSubscribeStreams =
  535. grpc.value("max_concurrent_subscribe_streams", config.grpc.maxConcurrentSubscribeStreams);
  536. config.grpc.maxConcurrentFileStreams =
  537. grpc.value("max_concurrent_file_streams", config.grpc.maxConcurrentFileStreams);
  538. }
  539. }
  540. return config;
  541. }
  542. void DatabaseService::setupComponents() {
  543. // v2.0 storage substrate (Stage 4 scaffold).
  544. //
  545. // Open the LMDB environment at <dataDir>/env/ — same path the v1.x
  546. // auto-migration tool in main.cpp writes to. By the time setupComponents
  547. // runs, either:
  548. // (a) the env already exists and contains migrated v2.0 data, or
  549. // (b) this is a fresh deployment and the env is empty.
  550. // Both cases are fine: LmdbDocumentStore opens sub-dbs lazily on first
  551. // put/read, so an empty env is a valid steady state.
  552. //
  553. // map_size_bytes / max_dbs match the values main.cpp uses for migration
  554. // so that opening the same env from both call sites is consistent.
  555. //
  556. // Failure to open the env is currently logged-and-tolerated: Stage 4
  557. // does not yet route any handlers through doc_store_, so a missing or
  558. // unopenable env should not kill the service. Stage 5+ may upgrade this
  559. // to a hard failure once handlers depend on the store.
  560. {
  561. smartbotic::db::storage::LmdbEnvOpts envOpts;
  562. envOpts.path = (config_.dataDirectory / "env").string();
  563. envOpts.map_size_bytes = 4ULL << 30; // 4 GiB initial; LMDB grows lazily.
  564. envOpts.max_dbs = 1024;
  565. std::error_code ec;
  566. std::filesystem::create_directories(envOpts.path, ec);
  567. if (ec) {
  568. spdlog::warn("v2.0 storage: failed to create env directory '{}': {} -- "
  569. "doc_store_ will be left null; Stage 4 still routes through "
  570. "MemoryStore so the service continues to boot",
  571. envOpts.path, ec.message());
  572. } else {
  573. try {
  574. env_ = std::make_unique<smartbotic::db::storage::LmdbEnv>(envOpts);
  575. doc_store_ = std::make_unique<smartbotic::db::storage::LmdbDocumentStore>(*env_);
  576. spdlog::info("v2.0 storage: LMDB env opened at {} (mapsize={}MB, max_dbs={})",
  577. envOpts.path, envOpts.map_size_bytes >> 20, envOpts.max_dbs);
  578. } catch (const std::exception& e) {
  579. spdlog::warn("v2.0 storage: failed to open LMDB env at '{}': {} -- "
  580. "doc_store_ will be left null; service continues with v1.x",
  581. envOpts.path, e.what());
  582. env_.reset();
  583. doc_store_.reset();
  584. }
  585. }
  586. }
  587. // Create memory store with eviction config
  588. MemoryStore::Config storeConfig;
  589. storeConfig.nodeId = config_.nodeId;
  590. storeConfig.maxMemoryBytes = config_.maxMemoryMb * 1024ULL * 1024ULL;
  591. storeConfig.evictionThresholdPercent = config_.evictionThresholdPercent;
  592. storeConfig.evictionTargetPercent = config_.evictionTargetPercent;
  593. storeConfig.evictionCheckIntervalMs = config_.evictionCheckIntervalMs;
  594. // NEW v1.7.0 eviction tuning (wired in T2; consumed in T3/T4).
  595. storeConfig.evictionChunkSize = config_.evictionChunkSize;
  596. storeConfig.evictionChunkPauseMs = config_.evictionChunkPauseMs;
  597. storeConfig.maxEvictionPassesPerTrigger = config_.maxEvictionPassesPerTrigger;
  598. storeConfig.hotWriteFloorMs = config_.hotWriteFloorMs;
  599. storeConfig.memorySoftPercent = config_.memorySoftPercent;
  600. storeConfig.memoryHardPercent = config_.memoryHardPercent;
  601. storeConfig.memoryEmergencyPercent = config_.memoryEmergencyPercent;
  602. storeConfig.evictionBurstThreshold = config_.evictionBurstThreshold;
  603. store_ = std::make_unique<MemoryStore>(storeConfig);
  604. // Create view manager (cache loaded in initialize() after persistence recovery)
  605. view_manager_ = std::make_unique<ViewManager>(*store_);
  606. // Create per-collection config manager and attach it to the store so the
  607. // write paths route document timestamp stamps through it.
  608. config_manager_ = std::make_unique<CollectionConfigManager>(*store_);
  609. store_->setConfigManager(config_manager_.get());
  610. // v1.9.0 — disk-resident version history. Replaces the in-heap
  611. // `CollectionData::versionHistory` deques that were the dominant
  612. // source of the Zoe untracked-RSS gap. One file per collection at
  613. // `<dataDir>/history/<collection>.hlog`. Lifecycle: constructed
  614. // here, wired into the store immediately so snapshot deserializer
  615. // can migrate pre-v1.9 in-memory history blocks straight to disk.
  616. HistoryStore::Config histConfig;
  617. histConfig.dataDir = config_.dataDirectory;
  618. history_store_ = std::make_unique<HistoryStore>(histConfig);
  619. store_->setHistoryStore(history_store_.get());
  620. // v2.0 Stage 4 — wire the LMDB mirror INTO MemoryStore. After this,
  621. // every write that flows through store_ also commits to doc_store_
  622. // BEFORE the per-collection lock releases. The persist callback no
  623. // longer has its own mirror block; it does only WAL/replication/events.
  624. if (doc_store_) {
  625. store_->setDocumentStoreMirror(doc_store_.get(),
  626. &mirror_healthy_,
  627. &mirror_drift_count_);
  628. }
  629. // Create persistence manager. Start from any persistenceConfig values the
  630. // caller (config loader / CLI parser) has already populated — including
  631. // recoveryMode — and overlay the top-level convenience fields.
  632. PersistenceManager::Config persistConfig = config_.persistenceConfig;
  633. persistConfig.dataDir = config_.dataDirectory;
  634. persistConfig.walSyncIntervalMs = config_.walSyncIntervalMs;
  635. persistConfig.snapshotIntervalSec = config_.snapshotIntervalSec;
  636. persistConfig.compressionEnabled = config_.compressionEnabled;
  637. persistConfig.nodeId = config_.nodeId;
  638. // Mirror the resolved recoveryMode back into our Config so downstream code
  639. // (logging, RPC handlers) can inspect config_.persistenceConfig.recoveryMode
  640. // without having to reach into the PersistenceManager.
  641. config_.persistenceConfig = persistConfig;
  642. persistence_ = std::make_unique<PersistenceManager>(persistConfig);
  643. // Connect store callbacks to persistence - uses setPersistCallback for WAL logging
  644. store_->setPersistCallback([this](const std::string& collection, const std::string& id,
  645. const std::optional<Document>& doc, EventType eventType) {
  646. // Log to WAL based on event type. v1.7.4: capture the assigned WAL
  647. // sequence and record it on the store so eviction stubs can carry
  648. // a real seq (instead of doc.version, which was useless as a
  649. // `fromSequence` hint and forced a full WAL scan on every fault).
  650. uint64_t walSeq = 0;
  651. switch (eventType) {
  652. case EventType::INSERT:
  653. if (doc) walSeq = persistence_->logInsert(collection, *doc);
  654. break;
  655. case EventType::UPDATE:
  656. if (doc) walSeq = persistence_->logUpdate(collection, *doc);
  657. break;
  658. case EventType::DELETE:
  659. persistence_->logDelete(collection, id);
  660. break;
  661. default:
  662. break;
  663. }
  664. if (walSeq > 0 && (eventType == EventType::INSERT || eventType == EventType::UPDATE)) {
  665. store_->recordWalSequence(collection, id, walSeq);
  666. }
  667. // v2.0 dual-write — moved INTO MemoryStore::mirrorWriteToDocStore so
  668. // that it runs UNDER the per-collection write lock. That closes the
  669. // race window where readers could see a doc in MemoryStore but not
  670. // yet in LMDB. The callback now does only WAL + replication + events.
  671. // Queue for replication broadcast
  672. if (replication_ && config_.replicationEnabled) {
  673. databasepb::ReplicationEntry entry;
  674. entry.set_collection(collection);
  675. entry.set_document_id(id);
  676. // v1.8.0 — stamp our nodeId so peers can attribute the write to
  677. // its actual origin. Pre-v1.8 broadcasts left this empty, which
  678. // is now treated by receivers as "legacy / unknown origin".
  679. entry.set_node_id(config_.nodeId);
  680. entry.set_global_timestamp(
  681. std::chrono::duration_cast<std::chrono::milliseconds>(
  682. std::chrono::system_clock::now().time_since_epoch()
  683. ).count());
  684. switch (eventType) {
  685. case EventType::INSERT:
  686. entry.set_op(databasepb::OP_INSERT);
  687. // Send the full Document envelope (id, collection, data,
  688. // version, timestamps, encryption state). The follower's
  689. // applyReplicatedEntry uses Document::fromJson() which
  690. // expects this envelope — sending only doc->data silently
  691. // dropped every user field on the other side.
  692. if (doc) entry.set_data(doc->toJson().dump());
  693. break;
  694. case EventType::UPDATE:
  695. entry.set_op(databasepb::OP_UPDATE);
  696. if (doc) entry.set_data(doc->toJson().dump());
  697. break;
  698. case EventType::DELETE:
  699. entry.set_op(databasepb::OP_DELETE);
  700. break;
  701. default:
  702. break;
  703. }
  704. replication_->queueForReplication(entry);
  705. }
  706. // Publish event
  707. if (events_) {
  708. DatabaseEvent event;
  709. event.type = eventType;
  710. event.collection = collection;
  711. event.documentId = id;
  712. event.timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
  713. std::chrono::system_clock::now().time_since_epoch()
  714. ).count();
  715. event.nodeId = config_.nodeId;
  716. if (doc) {
  717. event.data = doc->data();
  718. }
  719. events_->publish(event);
  720. }
  721. });
  722. // Set up document load callback for LRU eviction recovery.
  723. //
  724. // v1.7.4: walSequence is now a real WAL sequence (set by the persist
  725. // callback below at append time, not the per-doc version counter).
  726. // Pass `walSequence - 1` as the exclusive `fromSequence` so replay
  727. // returns entries with seq >= walSequence — the doc's last write is
  728. // exactly at walSequence, so the very first hit is the one we want.
  729. // For docs whose seq is unknown (0 — e.g. loaded from a snapshot
  730. // that predates v1.7.4), fall back to the full-WAL scan from 0.
  731. store_->setDocumentLoadCallback([this](const std::string& collection,
  732. const std::string& id,
  733. uint64_t walSequence) -> std::optional<Document> {
  734. uint64_t fromSequence = walSequence > 0 ? walSequence - 1 : 0;
  735. return persistence_->loadDocument(collection, id, fromSequence);
  736. });
  737. // Create encryption manager
  738. EncryptionManager::Config encryptConfig;
  739. encryptConfig.enabled = config_.encryptionEnabled;
  740. encryptConfig.keyFilePath = config_.keyFilePath;
  741. encryptConfig.autoGenerateKey = config_.autoGenerateKey;
  742. encryption_ = std::make_unique<EncryptionManager>(encryptConfig);
  743. // Create event manager
  744. EventManager::Config eventConfig;
  745. eventConfig.nodeId = config_.nodeId;
  746. events_ = std::make_unique<EventManager>(eventConfig);
  747. // Create file manager
  748. FileManager::Config fileConfig;
  749. fileConfig.filesDir = config_.dataDirectory / "files";
  750. fileConfig.maxFileSizeMb = config_.maxFileSizeMb;
  751. fileConfig.allowedMimeTypes = config_.allowedFileTypes;
  752. fileConfig.cleanupIntervalSec = config_.fileCleanupIntervalSec;
  753. files_ = std::make_unique<FileManager>(fileConfig);
  754. // Create replication manager
  755. ReplicationManager::Config replConfig;
  756. replConfig.nodeId = config_.nodeId;
  757. replConfig.peerAddresses = config_.peerAddresses;
  758. replConfig.conflictResolution = config_.conflictResolution;
  759. replication_ = std::make_unique<ReplicationManager>(replConfig);
  760. // Wire replication entry apply callback
  761. replication_->setEntryApplyCallback([this](const databasepb::ReplicationEntry& entry) {
  762. applyReplicatedEntry(entry);
  763. });
  764. // Create gRPC implementations
  765. storageImpl_ = std::make_unique<DatabaseGrpcImpl>(
  766. *this, *store_, *persistence_, *events_, *files_, *encryption_, *view_manager_, *config_manager_
  767. );
  768. // v1.6.2 — wire the streaming-RPC concurrency limits from GrpcConfig.
  769. storageImpl_->setStreamLimits(
  770. config_.grpc.maxConcurrentSubscribeStreams,
  771. config_.grpc.maxConcurrentFileStreams);
  772. replicationImpl_ = std::make_unique<DatabaseReplicationGrpcImpl>(
  773. *store_, *replication_, *persistence_
  774. );
  775. }
  776. void DatabaseService::applyReplicatedEntry(const databasepb::ReplicationEntry& entry) {
  777. try {
  778. // v1.8.0 — also append the replicated entry to the local WAL, tagged
  779. // with the originating node's id. This is what makes follower-side
  780. // recovery (eviction → WAL fallback, restart → WAL replay) preserve
  781. // replicated documents. Echo amplification is prevented by:
  782. // - GetEntriesSince emitting walEntry.nodeId (not local node id)
  783. // - SyncProtocol skipping entries whose origin is the peer itself
  784. // both implemented in the same v1.8.0 cycle.
  785. const std::string origin = entry.node_id().empty()
  786. ? config_.nodeId // legacy peer (pre-1.8) — best-guess local
  787. : entry.node_id();
  788. switch (entry.op()) {
  789. case databasepb::OP_INSERT:
  790. case databasepb::OP_UPDATE:
  791. case databasepb::OP_UPSERT: {
  792. if (entry.data().empty()) {
  793. spdlog::warn("Replication entry has no data for op {}", static_cast<int>(entry.op()));
  794. return;
  795. }
  796. auto json = smartbotic::db::parse_to_nlohmann(entry.data());
  797. Document doc = Document::fromJson(json);
  798. doc.id = entry.document_id();
  799. doc.nodeId = entry.node_id();
  800. // loadDocument bypasses the persist-and-broadcast callback to avoid
  801. // re-replicating; we explicitly write to the WAL right after with
  802. // the origin-aware overload so eviction/WAL recovery still works.
  803. store_->loadDocument(entry.collection(), doc);
  804. if (persistence_) {
  805. uint64_t walSeq = (entry.op() == databasepb::OP_INSERT)
  806. ? persistence_->logInsert(entry.collection(), doc, origin)
  807. : persistence_->logUpdate(entry.collection(), doc, origin);
  808. if (walSeq > 0) {
  809. store_->recordWalSequence(entry.collection(), doc.id, walSeq);
  810. }
  811. }
  812. spdlog::trace("Applied replicated {} to {}/{} from {}",
  813. entry.op() == databasepb::OP_INSERT ? "insert" :
  814. entry.op() == databasepb::OP_UPDATE ? "update" : "upsert",
  815. entry.collection(), entry.document_id(), entry.node_id());
  816. break;
  817. }
  818. case databasepb::OP_DELETE: {
  819. store_->remove(entry.collection(), entry.document_id());
  820. if (persistence_) {
  821. persistence_->logDelete(entry.collection(), entry.document_id(), origin);
  822. }
  823. spdlog::trace("Applied replicated delete to {}/{} from {}",
  824. entry.collection(), entry.document_id(), entry.node_id());
  825. break;
  826. }
  827. case databasepb::OP_CREATE_COLLECTION: {
  828. CollectionOptions options;
  829. store_->createCollection(entry.collection(), options);
  830. if (persistence_) {
  831. persistence_->logCreateCollection(entry.collection(), options, origin);
  832. }
  833. spdlog::trace("Applied replicated create collection {} from {}",
  834. entry.collection(), entry.node_id());
  835. break;
  836. }
  837. case databasepb::OP_DROP_COLLECTION: {
  838. store_->dropCollection(entry.collection());
  839. if (persistence_) {
  840. persistence_->logDropCollection(entry.collection(), origin);
  841. }
  842. spdlog::trace("Applied replicated drop collection {} from {}",
  843. entry.collection(), entry.node_id());
  844. break;
  845. }
  846. default:
  847. spdlog::warn("Unknown replication operation type: {}", static_cast<int>(entry.op()));
  848. break;
  849. }
  850. } catch (const std::exception& e) {
  851. spdlog::error("Failed to apply replicated entry: {}", e.what());
  852. }
  853. }
  854. void DatabaseService::startGrpcServer() {
  855. std::string serverAddress = config_.bindAddress + ":" + std::to_string(config_.rpcPort);
  856. grpc::ServerBuilder builder;
  857. builder.AddListeningPort(serverAddress, grpc::InsecureServerCredentials());
  858. builder.RegisterService(storageImpl_.get());
  859. builder.RegisterService(replicationImpl_.get());
  860. // Configurable message sizes (default 100 MB for file uploads)
  861. const int64_t mb = 1024 * 1024;
  862. builder.SetMaxReceiveMessageSize(
  863. static_cast<int>(config_.grpc.maxReceiveMessageSizeMb * mb));
  864. builder.SetMaxSendMessageSize(
  865. static_cast<int>(config_.grpc.maxSendMessageSizeMb * mb));
  866. // ResourceQuota bounds total inbound buffer memory across all RPCs
  867. grpc::ResourceQuota quota("smartbotic-db");
  868. quota.Resize(config_.grpc.resourceQuotaMemoryMb * mb);
  869. builder.SetResourceQuota(quota);
  870. spdlog::info("gRPC config: recv={}MB send={}MB quota={}MB subs={} files={}",
  871. config_.grpc.maxReceiveMessageSizeMb,
  872. config_.grpc.maxSendMessageSizeMb,
  873. config_.grpc.resourceQuotaMemoryMb,
  874. config_.grpc.maxConcurrentSubscribeStreams,
  875. config_.grpc.maxConcurrentFileStreams);
  876. grpcServer_ = builder.BuildAndStart();
  877. if (!grpcServer_) {
  878. throw std::runtime_error("Failed to start gRPC server on " + serverAddress);
  879. }
  880. spdlog::info("gRPC server listening on {}", serverAddress);
  881. }
  882. void DatabaseService::stopGrpcServer() {
  883. if (grpcServer_) {
  884. grpcServer_->Shutdown();
  885. grpcServer_.reset();
  886. }
  887. }
  888. } // namespace smartbotic::database