The explorer block cache searched with a LIKE query that had to escape wildcards, grew without any limit, and retried a failed open on every frame, including a two-second busy wait on the UI thread. - Search now uses an instr() substring test on a lowercased needle, and only checks the height text when the query is all digits, since a non-numeric needle can never appear inside a decimal height. - The table is pruned to the highest 20000 blocks, once per open and periodically as blocks are stored, so the file stops growing at its high-water mark. - A failed open is negative-cached (30 s by default) instead of retried every frame, and the busy timeout drops to 250 ms. - When the cache database is unavailable or refuses a write, the block spills to a small bounded in-memory map that both the list and search read, so a spilled block never appears in one but not the other; the spill is cleared when the chain tip regresses. - A batch of blocks is written under one savepoint instead of one commit per block. Tests: case-insensitive hash and height search, the negative-cache and spill paths with tip-regression eviction, and batch store plus prune. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
535 lines
20 KiB
C++
535 lines
20 KiB
C++
#include "explorer_block_cache.h"
|
|
|
|
#include "../../util/logger.h"
|
|
#include "../../util/platform.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <sqlite3.h>
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <utility>
|
|
|
|
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<std::int64_t>(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<long long>(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<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
|
|
{
|
|
std::map<int, ExplorerBlockSummary> 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<const char*>(hashText);
|
|
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
|
block.size = sqlite3_column_int(statement.handle, 3);
|
|
block.time = static_cast<std::int64_t>(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<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
|
{
|
|
std::vector<ExplorerBlockSummary> 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<char>(std::tolower(static_cast<unsigned char>(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<const char*>(hashText);
|
|
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
|
block.size = sqlite3_column_int(statement.handle, 3);
|
|
block.time = static_cast<std::int64_t>(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<sqlite3_int64>(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<ExplorerBlockSummary>& 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<const char*>(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
|