From 46371c69dff81b81a386c4a3fff67ce542c71e0d Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 13:17:58 -0500 Subject: [PATCH] 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 --- src/services/network_refresh_service.cpp | 12 +++++++++--- src/services/wallet_scan_governor.cpp | 12 ++++++++++++ tests/test_phase4.cpp | 12 ++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 10ff9fc..ce3c22f 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -38,6 +38,12 @@ std::optional readBalanceString(const json& source, const char* key) return std::nullopt; } +// Whole-wallet balance sweeps (z_gettotalbalance / z_listunspent) hold the daemon's cs_main and take tens +// of seconds on a very large note set. Give them a long reply timeout (like the sent-history sweep) so a +// slow pass completes and populates per-address balances instead of aborting at the default 30 s and +// leaving them at 0. The governor's cost-proportional floor keeps these from being issued too often. +constexpr long kBalanceSweepTimeoutSec = 120; + void applyBalancesFromUnspent(std::vector& addresses, const json& unspent) { if (!unspent.is_array()) return; @@ -397,7 +403,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre // (balanceRefreshDue()), keeping balance scans from starving block connection. const auto balanceStart = std::chrono::steady_clock::now(); try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater - totalBalance = rpc.call("z_gettotalbalance", json::array({0})); + totalBalance = rpc.callLong("z_gettotalbalance", json::array({0}), kBalanceSweepTimeoutSec); balanceOk = true; } catch (const std::exception& e) { balanceErrKind = rpc::rpcFailureKindOf(e); @@ -420,7 +426,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) { spendableQueried = true; try { // If it fails, spendable degrades to display in apply. - spendableBalance = rpc.call("z_gettotalbalance", json::array({1})); + spendableBalance = rpc.callLong("z_gettotalbalance", json::array({1}), kBalanceSweepTimeoutSec); } catch (...) {} } } @@ -832,7 +838,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres // failed (the caller discards the whole pass and keeps the previous lists). const bool scanBalances = !listOnly && result.addressListOk; if (scanBalances) try { - json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change + json unspent = rpc.callLong("z_listunspent", json::array({0, 9999999, false}), kBalanceSweepTimeoutSec); // minconf=0 → include 0-conf change applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent); // Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead // of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is diff --git a/src/services/wallet_scan_governor.cpp b/src/services/wallet_scan_governor.cpp index 76f3614..61f8265 100644 --- a/src/services/wallet_scan_governor.cpp +++ b/src/services/wallet_scan_governor.cpp @@ -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; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index efad5bb..43ec78c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -16991,6 +16991,18 @@ void testBalancePollDecisionAndVisibility() { auto x = back; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = back; x.visible = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } // hidden: its own interval + // Visible balance on a costly wallet: a whole-wallet sweep is floored to ~3x its own cost, so it can't + // re-poll before the node recovers (what saturates cs_main on huge note sets). Small wallets (cost ~ms) + // are effectively unaffected, and the forced/first-read paths bypass the floor entirely. + { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 30.0; // 30 < 3*20 = 60 -> hold off + EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } + { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 70.0; // 70 >= 60 -> allowed + EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } + { auto x = in; x.emaCostSec = 0.01; x.sinceLastScanSec = 5.0; // cheap wallet: floor ~0, unchanged + EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } + { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 1.0; x.forced = true; // user's own send bypasses it + EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } + using NP = dragonx::ui::NavPage; auto overview = scanVisibilityForPage(NP::Overview, false); EXPECT_TRUE(overview.balance && overview.addresses && overview.history && !overview.chat);