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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user