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
+84
View File
@@ -8590,6 +8590,90 @@ void testExplorerBlockCache()
EXPECT_EQ(reopened.cachedBlockCount(), 0);
}
// Search: substring of hash (case-insensitive) and of height text; non-digit needles skip heights.
{
ExplorerBlockCache searchCache(databasePath.string(), legacyPath.string());
for (int h = 100; h < 103; ++h) {
ExplorerBlockSummary b;
b.height = h;
b.hash = "abcdef" + std::to_string(h);
b.tx_count = 1;
EXPECT_TRUE(searchCache.storeBlock(b));
}
auto byHash = searchCache.searchBlocks("ABCDEF", 10);
EXPECT_EQ(byHash.size(), static_cast<size_t>(3));
EXPECT_EQ(byHash.front().height, 102);
EXPECT_EQ(searchCache.searchBlocks("101", 10).size(), static_cast<size_t>(1));
EXPECT_EQ(searchCache.searchBlocks("abcdef", 2).size(), static_cast<size_t>(2));
EXPECT_EQ(searchCache.searchBlocks("%", 10).size(), static_cast<size_t>(0));
}
// DB down (parent is a regular file): negative-cached open, rows spill to memory and stay searchable.
{
fs::path blocker = dir / "blocker";
{ std::ofstream f(blocker); f << "x"; }
ExplorerBlockCache down((blocker / "sub" / "explorer.sqlite").string(), std::string());
down.setOpenRetryInterval(std::chrono::milliseconds(10000));
for (int i = 0; i < 50; ++i) EXPECT_FALSE(down.ensureOpen());
EXPECT_EQ(down.openAttempts(), 1);
ExplorerBlockSummary b;
b.height = 777;
b.hash = "deadbeef777";
b.tx_count = 2;
EXPECT_FALSE(down.storeBlock(b));
EXPECT_EQ(down.spillCount(), static_cast<size_t>(1));
EXPECT_EQ(down.loadRange(777, 777).size(), static_cast<size_t>(1));
EXPECT_EQ(down.searchBlocks("DEADBE", 10).size(), static_cast<size_t>(1));
EXPECT_EQ(down.searchBlocks("777", 10).size(), static_cast<size_t>(1));
EXPECT_EQ(down.searchBlocks("nomatch", 10).size(), static_cast<size_t>(0));
// Tip regression clears the spill; an advancing tip keeps it.
down.prepareValidation(800, "tip-800");
down.prepareValidation(801, "tip-801");
EXPECT_EQ(down.spillCount(), static_cast<size_t>(1));
down.prepareValidation(790, "tip-790");
EXPECT_EQ(down.spillCount(), static_cast<size_t>(0));
for (int h = 1; h <= 600; ++h) {
ExplorerBlockSummary s;
s.height = h;
s.hash = "h" + std::to_string(h);
down.storeBlock(s);
}
EXPECT_EQ(down.spillCount(), static_cast<size_t>(512));
EXPECT_EQ(down.loadRange(600, 600).size(), static_cast<size_t>(1));
down.clearBlocks();
EXPECT_EQ(down.spillCount(), static_cast<size_t>(0));
// A zero retry interval retries every call.
ExplorerBlockCache eager((blocker / "sub" / "explorer.sqlite").string(), std::string());
eager.setOpenRetryInterval(std::chrono::milliseconds(0));
EXPECT_FALSE(eager.ensureOpen());
EXPECT_FALSE(eager.ensureOpen());
EXPECT_EQ(eager.openAttempts(), 2);
}
// storeBlocks batches into one savepoint; pruneTo keeps the highest rows.
{
ExplorerBlockCache batch((dir / "batch.sqlite").string(), std::string());
std::vector<ExplorerBlockSummary> blocks;
for (int h = 1; h <= 50; ++h) {
ExplorerBlockSummary s;
s.height = h;
s.hash = "b" + std::to_string(h);
blocks.push_back(s);
}
EXPECT_TRUE(batch.storeBlocks(blocks));
EXPECT_EQ(batch.cachedBlockCount(), 50);
EXPECT_TRUE(batch.storeBlocks(blocks));
EXPECT_EQ(batch.cachedBlockCount(), 50);
batch.pruneTo(20);
EXPECT_EQ(batch.cachedBlockCount(), 20);
EXPECT_EQ(batch.loadRange(1, 50).size(), static_cast<size_t>(20));
EXPECT_EQ(batch.loadRange(1, 50).begin()->first, 31);
}
cacheOwner.reset(); // close the database first: Windows can't delete an open file
removeTree(dir);
}