perf(net): one scan budget for all wallet scans, gated on what changed

The balance, address scan, history and probe were throttled one by one at
10% each, which added up to 30-40% of dragonxd's cs_main with all of them
due. services::WalletScanGovernor runs one wallet scan at a time across
both RPC lanes and keeps one shared quiet clock at 15% of wall-clock (cut
when the node is behind). Waiters are served first in, first out, with a
head start for what the page shows and a starvation floor of max(30 s
visible / 900 s hidden, 15x the stream's cost). Explicit work (a dirty
set, the user's send, a first load) is never refused but is charged.

Routine scans run only when the tip, the mempool size (getmempoolinfo:
no cs_main) or a wallet-mutation epoch changed since their last good scan,
or a safety sweep is due. Pages that don't show the balance poll it at
most every max(10 min, 50x its cost) (2 min with client-side auto-shield);
coming back into view with an old value fetches it at once. The staleness
badge counts from the last poll that confirmed nothing changed, and hides
while the budget holds the balance within its guarantee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 879de37d18
commit 323143e111
19 changed files with 1470 additions and 121 deletions
+44 -8
View File
@@ -821,6 +821,20 @@ void App::update()
if (fast_worker_) {
fast_worker_->drainResults();
}
// A scan ticket whose job is gone without a result (its task threw before returning a callback) would
// hold the scan budget's one slot: complete it as timed out. Safe right after the drains: a ticket is
// issued in the same call as its job, and a job's flag clears just before its result is applied.
{
using Job = services::NetworkRefreshService::Job;
using services::ScanStream;
const double scanNow = scanClockNow();
if (!network_refresh_.jobInProgress(Job::Core)) scan_governor_.dropOrphans(ScanStream::Balance, scanNow);
if (!network_refresh_.jobInProgress(Job::Addresses)) scan_governor_.dropOrphans(ScanStream::Addresses, scanNow);
if (!network_refresh_.jobInProgress(Job::Transactions)) {
scan_governor_.dropOrphans(ScanStream::History, scanNow);
scan_governor_.dropOrphans(ScanStream::RecentTx, scanNow);
}
}
// Apply any lite-wallet refresh the controller's background worker produced (main thread).
if (lite_wallet_) {
@@ -1356,6 +1370,9 @@ void App::update()
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
pending_opids_.end());
}
// An op finished (or vanished): whatever it did to the wallet, the scans gated on change
// signals must look again.
if (!terminalOpids.empty()) noteWalletMutation();
// Stale opids (no longer reported by the daemon): let any waiting send UI
// know the outcome couldn't be confirmed rather than spinning forever.
@@ -1418,10 +1435,11 @@ void App::update()
// runtime rescan, when the daemon holds cs_main and these calls would block.
if (!state_.warming_up && !runtime_rescan_active_) {
// History: refreshTransactionData() decides what this cycle may scan (services::decideTxCycle):
// wallet-wide scans only as their measured cost allows, except explicit work (the first load, an
// wallet-wide scans only as the scan budget allows, except explicit work (the first load, an
// invalidation, a new address); sends and other mutations get their rows through Light cycles.
// Held back by the budget, it asks again soon (a waiting stream must keep asking).
if (network_refresh_.consumeDue(RefreshTimer::Transactions) && shouldRunWalletTransactionRefresh()) {
refreshTransactionData();
if (refreshTransactionData()) network_refresh_.retrySoon(RefreshTimer::Transactions, kScanRetrySec);
}
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
// (slower, 10–15s) Transactions cadence — so incoming messages surface in a few seconds. The
@@ -1433,16 +1451,32 @@ void App::update()
chat_fast_scan_accum_ = 0.0f;
fastScanChatMemos();
}
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
if (network_refresh_.isDue(RefreshTimer::Addresses)) {
// Explicit need (a changed address set, or an in-flight send tracking its change output)
// must refresh now; the routine periodic poll on a wallet page is throttled by the last
// address scan's measured cost (addressRefreshDue) — this is the z_listunspent hammering
// that was starving cs_main while synced on a large wallet.
// refreshes now. The routine poll on a wallet page runs only when something it depends on
// changed (tip, mempool, a wallet mutation) or its safety sweep is due, and only as the scan
// budget allows; held back, it asks again soon.
bool retry = false;
if (addresses_dirty_ || hasTransactionSendProgress()) {
refreshAddressData();
} else if (walletDataPage && addressRefreshDue()) {
refreshAddressData();
} else if (walletDataPage && !effectivelySyncing()) {
using services::ScanStream;
const double scanNow = scanClockNow();
const auto scanSig = currentScanSignals();
if (scan_governor_.needed(ScanStream::Addresses, scanSig, scanNow)) {
if (network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Addresses)) {
retry = true;
} else if (auto ticket = scan_governor_.tryAdmit(ScanStream::Addresses,
currentScanVisibility().addresses,
scanSig, scanNow)) {
refreshAddressData(ticket);
} else {
retry = true;
}
}
}
if (retry) network_refresh_.retrySoon(RefreshTimer::Addresses, kScanRetrySec);
else network_refresh_.reset(RefreshTimer::Addresses);
}
}
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
@@ -5253,6 +5287,8 @@ void App::refreshNow()
{
// Trigger immediate refresh on all categories
network_refresh_.markImmediateRefresh();
noteWalletMutation(); // the user asked: the change-gated scans run, the balance too
requestForcedBalanceRefresh();
markTransactionsDirty(); // Force transaction list update
addresses_dirty_ = true; // Force address/balance update
last_tx_block_height_ = -1; // Reset tx cache