#include "explorer_block_cache.h" #include "../../util/logger.h" #include "../../util/platform.h" #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; using json = nlohmann::json; namespace dragonx { namespace ui { namespace { constexpr int kCacheSchemaVersion = 1; struct Statement { sqlite3_stmt* handle = nullptr; Statement(sqlite3* db, const char* sql) { if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) { handle = nullptr; } } ~Statement() { if (handle) sqlite3_finalize(handle); } Statement(const Statement&) = delete; Statement& operator=(const Statement&) = delete; }; bool bindText(sqlite3_stmt* statement, int index, const std::string& value) { return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK; } bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block) { if (!source.is_object()) return false; try { block.height = source.value("height", 0); block.hash = source.value("hash", std::string()); block.tx_count = source.value("tx_count", 0); block.size = source.value("size", 0); block.time = source.value("time", static_cast(0)); block.difficulty = source.value("difficulty", 0.0); } catch (...) { return false; } return block.height > 0 && !block.hash.empty(); } } // namespace ExplorerBlockCache::ExplorerBlockCache() : ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath()) { } ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath) : database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath)) { } ExplorerBlockCache::~ExplorerBlockCache() { close(); } std::string ExplorerBlockCache::defaultDatabasePath() { return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string(); } std::string ExplorerBlockCache::defaultLegacyJsonPath() { return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string(); } bool ExplorerBlockCache::failOpen(const char* why) { retry_not_before_ = std::chrono::steady_clock::now() + open_retry_interval_; DEBUG_LOGF("Explorer block cache unavailable (%s); retrying in %lld ms\n", why, static_cast(open_retry_interval_.count())); return false; } bool ExplorerBlockCache::ensureOpen() { if (db_) return true; // Negative cache: ~6 cache calls per frame used to each redo create_directories + open + PRAGMAs + CREATE // TABLE (and a 2 s busy wait on the UI thread) when the file was unusable. if (retry_not_before_ != std::chrono::steady_clock::time_point{} && std::chrono::steady_clock::now() < retry_not_before_) return false; ++open_attempts_; try { fs::path path(database_path_); if (!path.parent_path().empty()) fs::create_directories(path.parent_path()); } catch (const std::exception& e) { return failOpen(e.what()); } sqlite3* openedDb = nullptr; if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) { const bool failed = failOpen(openedDb ? sqlite3_errmsg(openedDb) : "unknown error"); if (openedDb) sqlite3_close(openedDb); return failed; } db_ = openedDb; // UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of // freezing the frame for 2 s. sqlite3_busy_timeout(db_, 250); exec("PRAGMA journal_mode=WAL"); exec("PRAGMA synchronous=NORMAL"); if (!createSchema()) { close(); return failOpen("schema"); } retry_not_before_ = {}; migrateLegacyJsonIfNeeded(); pruneTo(kMaxCachedBlocks); // once per open, never per frame return true; } std::map ExplorerBlockCache::loadRange(int minHeight, int maxHeight) { std::map blocks; if (minHeight > maxHeight) std::swap(minHeight, maxHeight); if (minHeight < 1 || maxHeight < 1) return blocks; if (ensureOpen()) { Statement statement(db_, "SELECT height, hash, tx_count, size, time, difficulty " "FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC"); if (statement.handle) { sqlite3_bind_int(statement.handle, 1, minHeight); sqlite3_bind_int(statement.handle, 2, maxHeight); while (sqlite3_step(statement.handle) == SQLITE_ROW) { ExplorerBlockSummary block; block.height = sqlite3_column_int(statement.handle, 0); const unsigned char* hashText = sqlite3_column_text(statement.handle, 1); if (hashText) block.hash = reinterpret_cast(hashText); block.tx_count = sqlite3_column_int(statement.handle, 2); block.size = sqlite3_column_int(statement.handle, 3); block.time = static_cast(sqlite3_column_int64(statement.handle, 4)); block.difficulty = sqlite3_column_double(statement.handle, 5); if (block.height > 0 && !block.hash.empty()) { blocks[block.height] = std::move(block); } } } } // Heights the DB could not give (closed, or refused the insert) come from the in-memory spill. for (auto it = spill_.lower_bound(minHeight); it != spill_.end() && it->first <= maxHeight; ++it) blocks.emplace(it->first, it->second); // emplace never overwrites a DB row return blocks; } std::vector ExplorerBlockCache::searchBlocks(const std::string& query, int limit) { std::vector results; if (query.empty() || limit < 1) return results; // instr() is a plain substring test (no wildcards to escape). Daemon hashes are lowercase hex, so lowercase // the needle (LIKE was ASCII case-insensitive). A non-numeric needle can never occur inside a decimal // height, so only an all-digit query also checks the height text. std::string needle = query; for (char& c : needle) c = static_cast(std::tolower(static_cast(c))); const bool digitsOnly = std::all_of(needle.begin(), needle.end(), [](char ch) { return ch >= '0' && ch <= '9'; }); if (!ensureOpen()) { // DB down: search the in-memory spill instead of returning nothing for (auto it = spill_.rbegin(); it != spill_.rend() && (int)results.size() < limit; ++it) if (it->second.hash.find(needle) != std::string::npos || (digitsOnly && std::to_string(it->first).find(needle) != std::string::npos)) results.push_back(it->second); return results; } Statement statement(db_, digitsOnly ? "SELECT height, hash, tx_count, size, time, difficulty FROM explorer_blocks " "WHERE instr(CAST(height AS TEXT), ?1) > 0 OR instr(hash, ?1) > 0 ORDER BY height DESC LIMIT ?2" : "SELECT height, hash, tx_count, size, time, difficulty FROM explorer_blocks " "WHERE instr(hash, ?1) > 0 ORDER BY height DESC LIMIT ?2"); if (!statement.handle) return results; sqlite3_bind_text(statement.handle, 1, needle.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(statement.handle, 2, limit); while (sqlite3_step(statement.handle) == SQLITE_ROW) { ExplorerBlockSummary block; block.height = sqlite3_column_int(statement.handle, 0); const unsigned char* hashText = sqlite3_column_text(statement.handle, 1); if (hashText) block.hash = reinterpret_cast(hashText); block.tx_count = sqlite3_column_int(statement.handle, 2); block.size = sqlite3_column_int(statement.handle, 3); block.time = static_cast(sqlite3_column_int64(statement.handle, 4)); block.difficulty = sqlite3_column_double(statement.handle, 5); if (block.height > 0 && !block.hash.empty()) results.push_back(std::move(block)); } // A block the DB refused (transient busy) is held in spill_ and shows in the list via loadRange, so // keep search consistent: merge any matching spill entry the DB query did not already return. for (auto it = spill_.rbegin(); it != spill_.rend() && (int)results.size() < limit; ++it) { if (!(it->second.hash.find(needle) != std::string::npos || (digitsOnly && std::to_string(it->first).find(needle) != std::string::npos))) continue; if (std::any_of(results.begin(), results.end(), [&](const ExplorerBlockSummary& b) { return b.height == it->first; })) continue; results.push_back(it->second); } return results; } bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block) { if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false; Statement statement(db_, "INSERT OR REPLACE INTO explorer_blocks " "(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)"); if (!statement.handle) return false; sqlite3_bind_int(statement.handle, 1, block.height); if (!bindText(statement.handle, 2, block.hash)) return false; sqlite3_bind_int(statement.handle, 3, block.tx_count); sqlite3_bind_int(statement.handle, 4, block.size); sqlite3_bind_int64(statement.handle, 5, static_cast(block.time)); sqlite3_bind_double(statement.handle, 6, block.difficulty); return sqlite3_step(statement.handle) == SQLITE_DONE; } bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block) { if (block.height < 1 || block.hash.empty()) return false; const bool stored = storeBlockRow(block); if (stored) { spill_.erase(block.height); if (++stores_since_prune_ >= 1000) { stores_since_prune_ = 0; pruneTo(kMaxCachedBlocks); } } else { spillBlock(block); } return stored; } bool ExplorerBlockCache::storeBlocks(const std::vector& blocks) { if (blocks.empty()) return true; // A savepoint, not BEGIN: nests inside a caller's transaction (same reason as updateTip). const bool grouped = ensureOpen() && exec("SAVEPOINT explorer_batch"); bool all = true; for (const auto& b : blocks) all = storeBlock(b) && all; if (grouped) exec("RELEASE explorer_batch"); return all; } void ExplorerBlockCache::spillBlock(const ExplorerBlockSummary& block) { spill_[block.height] = block; while (spill_.size() > kMaxSpillBlocks) { const int farFirst = std::abs(spill_.begin()->first - block.height); const int farLast = std::abs(spill_.rbegin()->first - block.height); spill_.erase(farFirst >= farLast ? spill_.begin() : std::prev(spill_.end())); } } int ExplorerBlockCache::cachedBlockCount() { if (!ensureOpen()) return 0; Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks"); if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0; return sqlite3_column_int(statement.handle, 0); } void ExplorerBlockCache::clearBlocks() { spill_.clear(); if (!ensureOpen()) return; exec("DELETE FROM explorer_blocks"); } void ExplorerBlockCache::pruneTo(int maxRows) { if (maxRows < 1 || !db_) return; // Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the // subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water // mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases). Statement statement(db_, "DELETE FROM explorer_blocks WHERE height <= " "(SELECT height FROM explorer_blocks ORDER BY height DESC LIMIT 1 OFFSET ?1)"); if (!statement.handle) return; sqlite3_bind_int(statement.handle, 1, maxRows); sqlite3_step(statement.handle); } ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation( int currentHeight, const std::string& currentBestHash) { SavedTipValidation validation; if (currentHeight <= 0) return validation; // Steady state: the tip hasn't moved since it was saved. This runs every frame the Explorer is // open, and used to read both meta rows and rewrite them each time (two autocommit WAL writes per // frame). Only an exact match takes the shortcut; anything else goes through the checks below. if (tip_known_ && !currentBestHash.empty() && currentHeight == tip_height_ && currentBestHash == tip_hash_) { return validation; } if (!ensureOpen()) { // No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height). if (!spill_.empty() && !currentBestHash.empty() && (currentHeight < spill_tip_height_ || (currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) spill_.clear(); if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; } return validation; } int savedHeight = getMetaInt("tip_height", 0); std::string savedHash = getMetaValue("tip_hash"); if (savedHeight <= 0 || savedHash.empty()) { if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash); return validation; } if (currentHeight < savedHeight) { clearBlocks(); if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash); else updateTip(0, std::string()); return validation; } if (currentHeight == savedHeight) { if (currentBestHash.empty()) return validation; if (currentBestHash != savedHash) { clearBlocks(); // same height, different block: a reorg updateTip(currentHeight, currentBestHash); } else { // Already saved: remember it so later frames skip SQLite (nothing to write). tip_known_ = true; tip_height_ = savedHeight; tip_hash_ = savedHash; } return validation; } if (currentBestHash.empty()) return validation; validation.needed = true; validation.height = savedHeight; validation.expectedHash = savedHash; return validation; } void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation, const std::string& actualHash, int currentHeight, const std::string& currentBestHash) { if (!validation.needed || !ensureOpen()) return; if (actualHash.empty()) return; if (actualHash != validation.expectedHash) { clearBlocks(); } if (currentHeight > 0 && !currentBestHash.empty()) { updateTip(currentHeight, currentBestHash); } } void ExplorerBlockCache::updateTip(int height, const std::string& hash) { tip_known_ = false; if (!ensureOpen()) return; // Both rows in one transaction, so a crash between them can't save a new height with the old hash // (which would wipe the cache for nothing on the next run). A savepoint, not BEGIN, so it nests // inside a caller's transaction instead of failing. if (!exec("SAVEPOINT explorer_tip")) return; const bool written = setMetaValue("tip_height", std::to_string(std::max(0, height))) && setMetaValue("tip_hash", hash); if (!written) exec("ROLLBACK TO explorer_tip"); const bool released = exec("RELEASE explorer_tip"); if (written && released && height > 0 && !hash.empty()) { tip_known_ = true; tip_height_ = height; tip_hash_ = hash; } } int ExplorerBlockCache::totalChanges() const { return db_ ? sqlite3_total_changes(db_) : 0; } bool ExplorerBlockCache::exec(const char* sql) { if (!db_) return false; char* error = nullptr; int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error); if (result != SQLITE_OK) { DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_)); if (error) sqlite3_free(error); return false; } return true; } std::string ExplorerBlockCache::getMetaValue(const std::string& key) { if (!ensureOpen()) return {}; Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?"); if (!statement.handle) return {}; if (!bindText(statement.handle, 1, key)) return {}; if (sqlite3_step(statement.handle) != SQLITE_ROW) return {}; const unsigned char* valueText = sqlite3_column_text(statement.handle, 0); return valueText ? reinterpret_cast(valueText) : std::string(); } int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback) { std::string value = getMetaValue(key); if (value.empty()) return fallback; try { return std::stoi(value); } catch (...) { return fallback; } } bool ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value) { if (!ensureOpen()) return false; Statement statement(db_, "INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)"); if (!statement.handle) return false; if (!bindText(statement.handle, 1, key)) return false; if (!bindText(statement.handle, 2, value)) return false; return sqlite3_step(statement.handle) == SQLITE_DONE; } bool ExplorerBlockCache::createSchema() { return exec("CREATE TABLE IF NOT EXISTS explorer_blocks (" "height INTEGER PRIMARY KEY," "hash TEXT NOT NULL," "tx_count INTEGER NOT NULL," "size INTEGER NOT NULL," "time INTEGER NOT NULL," "difficulty REAL NOT NULL)") && exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta (" "key TEXT PRIMARY KEY," "value TEXT NOT NULL)") && exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES " "('schema_version', '1')"); } void ExplorerBlockCache::migrateLegacyJsonIfNeeded() { if (!db_ || getMetaValue("json_migrated") == "1") return; bool migrated = false; try { if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) { std::ifstream file(legacy_json_path_); json cache; file >> cache; if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) { exec("BEGIN IMMEDIATE TRANSACTION"); if (cache.contains("blocks") && cache["blocks"].is_array()) { for (const auto& entry : cache["blocks"]) { ExplorerBlockSummary block; if (blockSummaryFromJson(entry, block)) { storeBlock(block); } } } exec("COMMIT"); int tipHeight = cache.value("tip_height", 0); std::string tipHash = cache.value("tip_hash", std::string()); if (tipHeight > 0 && !tipHash.empty()) { updateTip(tipHeight, tipHash); } } } migrated = true; } catch (const std::exception& e) { exec("ROLLBACK"); DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what()); migrated = true; } if (migrated) setMetaValue("json_migrated", "1"); } void ExplorerBlockCache::close() { tip_known_ = false; if (!db_) return; sqlite3_close(db_); db_ = nullptr; } } // namespace ui } // namespace dragonx