From 648e54b02d3677246fecd42e2e9233aea8dd57e5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 4 Jun 2026 12:32:28 -0400 Subject: [PATCH] fix: add cycle block cache to InstantSendManager --- .../bitcoinj/quorums/InstantSendManager.java | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/bitcoinj/quorums/InstantSendManager.java b/core/src/main/java/org/bitcoinj/quorums/InstantSendManager.java index 6c2ea9f5e..d1014fdb2 100644 --- a/core/src/main/java/org/bitcoinj/quorums/InstantSendManager.java +++ b/core/src/main/java/org/bitcoinj/quorums/InstantSendManager.java @@ -29,6 +29,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -64,6 +65,32 @@ public class InstantSendManager implements RecoveredSignatureListener { // Incoming and not verified yet HashMap> pendingInstantSendLocks; + // Cycle hash lookup cache. Every deterministic islock carries the cycleHash of the + // first block of its DKG cycle, so the same few hashes are looked up over and over. + // Without this cache each islock falls through to BlockStore.get(); SPVBlockStore's + // miss path linearly scans the entire memory-mapped ring buffer under the store + // lock -- on Android each buffer read is a JNI call, and the lookup runs on the + // network thread, where it has been observed starving other threads of the store + // lock (e.g. wedging PeerGroup.stop() indefinitely). + private static final int CYCLE_HASH_CACHE_SIZE = 100; + private final LinkedHashMap cycleHashCache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry entry) { + return size() > CYCLE_HASH_CACHE_SIZE; + } + }; + // Cycle hashes known to be missing from the block store. A missing cycle block may + // arrive later, so an entry is invalidated when the block with that hash connects + // (see newBestBlockListener). + private final LinkedHashMap cycleHashNotFoundCache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry entry) { + return size() > CYCLE_HASH_CACHE_SIZE; + } + }; + public InstantSendManager(Context context, InstantSendDatabase db, SigningManager signingManager, boolean runWithoutThread) { this.context = context; this.db = db; @@ -125,6 +152,11 @@ public void close(PeerGroup peerGroup) { blockChain.removeNewBestBlockListener(this.newBestBlockListener); blockChain = null; } + // the block store may be different after a restart or reset + synchronized (cycleHashCache) { + cycleHashCache.clear(); + cycleHashNotFoundCache.clear(); + } if (peerGroup != null) { peerGroup.removeOnTransactionBroadcastListener(this.transactionBroadcastListener); peerGroup.removePreMessageReceivedEventListener(preMessageReceivedEventListener); @@ -159,6 +191,34 @@ public boolean isInstantSendEnabled() { } } + /** + * Looks up the block identified by an islock's cycleHash, caching both hits and + * misses to avoid repeated {@link BlockStore#get(Sha256Hash)} ring-buffer scans on + * hot threads. A cached miss is re-checked once the block with that hash connects. + */ + @Nullable + private StoredBlock getCycleBlock(Sha256Hash cycleHash) throws BlockStoreException { + synchronized (cycleHashCache) { + StoredBlock cached = cycleHashCache.get(cycleHash); + if (cached != null) { + return cached; + } + if (cycleHashNotFoundCache.containsKey(cycleHash)) { + return null; + } + } + StoredBlock block = blockChain.getBlockStore().get(cycleHash); + synchronized (cycleHashCache) { + if (block != null) { + cycleHashCache.put(cycleHash, block); + cycleHashNotFoundCache.remove(cycleHash); + } else { + cycleHashNotFoundCache.put(cycleHash, Boolean.TRUE); + } + } + return block; + } + public void processInstantSendLock(Peer peer, InstantSendLock isLock) { if(!isInstantSendEnabled()) return; @@ -170,7 +230,7 @@ public void processInstantSendLock(Peer peer, InstantSendLock isLock) { if (isLock.isDeterministic()) { try { - StoredBlock blockIndex = blockChain.getBlockStore().get(isLock.cycleHash); + StoredBlock blockIndex = getCycleBlock(isLock.cycleHash); if (blockIndex == null) { // Maybe we don't have the block yet or maybe some peer spams invalid values for cycleHash // TODO: DashCore increases ban score by 1 @@ -561,7 +621,7 @@ HashSet processPendingInstantSendLocks(LLMQParameters.LLMQType llmqT final StoredBlock blockIndex; try { - blockIndex = blockChain.getBlockStore().get(islock.cycleHash); + blockIndex = getCycleBlock(islock.cycleHash); } catch (BlockStoreException e) { throw new RuntimeException(e); } catch (NullPointerException e) { @@ -852,6 +912,10 @@ public void syncTransaction(Transaction tx, StoredBlock block, int posInBlock) { NewBestBlockListener newBestBlockListener = new NewBestBlockListener() { @Override public void notifyNewBestBlock(StoredBlock block) throws VerificationException { + // this block may be a cycle block that an earlier islock lookup missed + synchronized (cycleHashCache) { + cycleHashNotFoundCache.remove(block.getHeader().getHash()); + } if (sporkManager.isSporkActive(SporkId.SPORK_19_CHAINLOCKS_ENABLED)) { // Nothing to do here. We should keep all islocks and let chainlocks handle them.