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
+10 -3
View File
@@ -423,15 +423,22 @@ static void RenderBalanceClassic(App* app)
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
// dropping the whole connection). The node banner only covers full disconnects, so
// this pill is the sole signal that the shown number may be out of date.
StalenessBadge badge = evaluateStalenessBadge(
state.last_balance_update, std::time(nullptr), state.connected);
// Current as of the later of: the last balance fetch, and the last Core poll that found
// nothing it depends on changed (the scan budget skips the scan then). Not while a balance
// scan is in flight (the new value is on its way), nor while the scan budget holds the next
// one within what it promises, unless the last attempt failed.
const std::int64_t balanceAsOf = std::max(state.last_balance_update, state.balance_verified_at);
const bool budgetHeld = !state.balance_last_attempt_failed && app->isBalanceHeldByScanBudget();
StalenessBadge badge = (app->isBalanceRefreshInFlight() || budgetHeld)
? StalenessBadge{}
: evaluateStalenessBadge(balanceAsOf, std::time(nullptr), state.connected);
if (badge.show) {
const bool err = (badge.severity == StalenessSeverity::Error);
ImU32 fg = err ? Error() : Warning();
ImU32 bg = WithAlpha(fg, 38);
ImU32 bd = WithAlpha(fg, 90);
snprintf(buf, sizeof(buf), "%s %s",
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
TR("data_stale_prefix"), timeAgo(balanceAsOf).c_str());
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd, ImVec2(4.0f * dp, 2.0f * dp));
// Hover → explain what stale means and how old the data actually is.
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
@@ -79,6 +79,9 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
}
return [this, result_str, is_error]() {
in_flight_.fetch_sub(1);
// A Console command may have changed the wallet (a send, an import, a new address): the
// scans gated on change signals must look again. (Main thread: this is a drained callback.)
if (app_) app_->noteWalletMutation();
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
};
@@ -93,6 +96,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
result_str = e.what();
is_error = true;
}
if (app_) app_->noteWalletMutation();
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
}