feat(explorer): indexed block search with a bounded cache

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>
This commit is contained in:
2026-10-13 09:24:51 -05:00
co-authored by Claude Opus 4.8
parent c3f981b52e
commit ebbeb846e8
3 changed files with 255 additions and 42 deletions
+30
View File
@@ -1,5 +1,7 @@
#pragma once
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <map>
#include <string>
@@ -45,9 +47,21 @@ public:
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
// Always returns the DB result; a block the DB refuses (unopenable file, failed insert) is kept in a small
// in-memory spill so the Explorer still shows it instead of refetching it forever.
bool storeBlock(const ExplorerBlockSummary& block);
// storeBlock for a batch inside one SAVEPOINT (one WAL commit instead of one per block). True if every block hit the DB.
bool storeBlocks(const std::vector<ExplorerBlockSummary>& blocks);
int cachedBlockCount();
void clearBlocks();
// Keep only the `maxRows` highest blocks (data-safe: the table is a re-fetchable RPC cache).
void pruneTo(int maxRows);
static constexpr int kMaxCachedBlocks = 20000;
// A failed open is not retried until this interval has passed (default 30 s). openAttempts() counts real attempts.
void setOpenRetryInterval(std::chrono::milliseconds interval) { open_retry_interval_ = interval; }
int openAttempts() const { return open_attempts_; }
std::size_t spillCount() const { return spill_.size(); }
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
void applySavedTipValidation(const SavedTipValidation& validation,
@@ -68,6 +82,9 @@ private:
bool createSchema();
void migrateLegacyJsonIfNeeded();
void close();
bool storeBlockRow(const ExplorerBlockSummary& block); // DB write only
void spillBlock(const ExplorerBlockSummary& block);
bool failOpen(const char* why);
sqlite3* db_ = nullptr;
std::string database_path_;
@@ -78,6 +95,19 @@ private:
bool tip_known_ = false;
int tip_height_ = 0;
std::string tip_hash_;
// Failure latch for ensureOpen(): after a failed open nothing retries until retry_not_before_.
std::chrono::steady_clock::time_point retry_not_before_{};
std::chrono::milliseconds open_retry_interval_{30000};
int open_attempts_ = 0;
int stores_since_prune_ = 0;
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
static constexpr std::size_t kMaxSpillBlocks = 512;
std::map<int, ExplorerBlockSummary> spill_;
int spill_tip_height_ = 0;
std::string spill_tip_hash_;
};
} // namespace ui