perf(balance): stop large-wallet balance polls from saturating the node
On a wallet with a very large shielded note set, the daemon re-decrypts all notes under cs_main on every z_gettotalbalance / z_listunspent (GetFilteredNotes), ~15-33s per pass. The GUI issued these roughly every 15s and timed them out at the 30s RPC limit, so the node looked permanently "busy" and per-address balances read 0 (nothing to carry on a fresh session) while the total was fine. Two wallet-side mitigations: - A cost-proportional floor on VISIBLE balance polls (wallet_scan_governor): the adaptive backoff previously applied only when the balance was off-screen. It now also holds a visible poll until ~3x the measured scan cost has elapsed, so a wallet whose sweep costs tens of seconds can't re-poll before the node recovers. Negligible for small wallets (cost ~ms); forced / first-read bypass it. - The heavy balance RPCs (z_gettotalbalance, z_listunspent) now use a 120s reply timeout via the existing callLong path, so a slow pass completes and populates per-address balances instead of aborting at 30s and leaving them at 0. The root fix is daemon-side (cache decrypted notes so GetFilteredNotes isn't O(notes x decrypt) per call); specced separately. Adds governor test coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,13 @@ double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
|
||||
return std::max(floorSec, 50.0 * std::max(0.0, emaCostSec));
|
||||
}
|
||||
|
||||
// Minimum gap between VISIBLE balance scans, as a multiple of the measured scan cost. A whole-wallet
|
||||
// z_gettotalbalance / z_listunspent holds the daemon's cs_main for its whole run (tens of seconds on a
|
||||
// very large note set); re-polling before it recovers starves block connection and times the next scan
|
||||
// out, which leaves per-address balances at 0. 3x the cost gives the node room to breathe while staying
|
||||
// responsive — on a normal wallet the cost is milliseconds, so this floor is effectively zero.
|
||||
constexpr double kVisibleBalanceCostMultiple = 3.0;
|
||||
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
{
|
||||
if (!in.allowBalance || !in.gateOpen) return BalancePollDecision::Skip;
|
||||
@@ -57,6 +64,11 @@ BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
if (in.forced && !in.background) return BalancePollDecision::Explicit;
|
||||
if (!in.changed) return BalancePollDecision::Skip;
|
||||
if (in.visible) {
|
||||
// Cost-proportional floor: don't re-scan before ~3x the measured scan cost has elapsed, so a
|
||||
// wallet whose whole-wallet sweep costs many seconds can't saturate the node by re-polling before
|
||||
// it recovers. Negligible for small wallets. The forced / first-read paths above bypass this.
|
||||
if (in.sinceLastScanSec < kVisibleBalanceCostMultiple * std::max(0.0, in.emaCostSec))
|
||||
return BalancePollDecision::Skip;
|
||||
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
|
||||
if (!in.waiting && in.shownAgeSec >= in.visibleFloorSec && in.sinceLastScanSec >= in.visibleFloorSec)
|
||||
return BalancePollDecision::Explicit;
|
||||
|
||||
Reference in New Issue
Block a user