wallet: decrypt big note-cache batches on worker threads

The startup warm-up, or a lazy fill after -notecachewarmup=0, decrypts
the whole wallet: ~0.5ms per note, ~6s serial for 12k notes. Each
decryption is independent: it reads its own output and writes only its
own note's cachedPlaintext. So FillSaplingNoteCache now splits batches
of 128 or more notes round-robin across -witnessbuildthreads (default:
hardware_concurrency) workers, at least 64 notes per thread. It follows
BuildWitnessCache's Phase 2 pattern:

- the caller keeps cs_wallet held throughout;
- workers take no lock and assert none; under DEBUG_LOCKORDER a fresh
  thread has no lock stack;
- per-shard fill counters are summed after join;
- nothing escapes a worker: a throwing decryption leaves that note
  unfilled, like one that doesn't decrypt;
- if the OS refuses a thread, the remaining shards run inline.

librustzcash is already called concurrently by BuildWitnessCache's
workers, and libsodium is thread-safe after sodium_init.

ParallelFillMatchesSerial: 300 notes across two keys and diversified
addresses decrypt to exactly the same cache with 1, 4 and 7 threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 08:16:09 -05:00
co-authored by Claude Opus 5.5
parent 83a14a7bfb
commit 43635b7fd7
2 changed files with 102 additions and 8 deletions
+51
View File
@@ -815,3 +815,54 @@ TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) {
EXPECT_EQ(total - 3, cached);
EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked";
}
// Big batches are decrypted on several threads; the result is exactly the serial one.
TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) {
NoteCacheWallet wallet;
auto skA = TestKey(0), skB = TestKey(1);
auto addrsA = AddressesOf(skA);
LOCK2(cs_main, wallet.cs_wallet);
ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress()));
uint64_t value = 0;
for (int t = 0; t < 30; t++) {
std::vector<OutputDescription> outs;
for (int i = 0; i < 10; i++)
outs.push_back(MakeOutput(i % 3 == 2 ? skB.DefaultAddress() : addrsA[i % addrsA.size()], ++value));
CTransaction tx = MakeTx(outs);
CWalletTx wtx(&wallet, tx);
mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first;
ASSERT_EQ(10u, found.size());
wtx.SetSaplingNoteData(found);
ASSERT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
}
auto snapshot = [&]() {
std::vector<std::pair<SaplingOutPoint, SaplingNoteCache>> out;
for (const auto& p : wallet.mapWallet)
for (const auto& item : p.second.mapSaplingNoteData)
if (item.second.cachedPlaintext)
out.push_back(std::make_pair(item.first, *item.second.cachedPlaintext));
return out;
};
auto eager = snapshot();
ASSERT_EQ(300u, eager.size());
for (const char* threads : {"1", "4", "7"}) {
mapArgs["-witnessbuildthreads"] = threads;
wallet.ClearPlaintextCache();
const uint64_t base = wallet.nSaplingNoteCacheFills;
wallet.WarmSaplingNoteCache();
EXPECT_EQ(base + 300, wallet.nSaplingNoteCacheFills) << threads << " thread(s)";
auto filled = snapshot();
ASSERT_EQ(eager.size(), filled.size()) << threads << " thread(s)";
for (size_t i = 0; i < eager.size(); i++) {
EXPECT_TRUE(eager[i].first == filled[i].first);
const SaplingNote& a = eager[i].second.note;
const SaplingNote& b = filled[i].second.note;
EXPECT_TRUE(a.d == b.d && a.pk_d == b.pk_d && a.r == b.r && a.value() == b.value()) << threads << " thread(s), note " << i;
EXPECT_TRUE(eager[i].second.memo == filled[i].second.memo);
}
}
mapArgs.erase("-witnessbuildthreads");
}