fix(sync): stop large-wallet balance polling from starving block connection
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was held almost continuously, starving the single block-connection thread: the node connected blocks only in the gaps between polls and could fall further behind the tip than it caught up (observed live: gap growing 58→100 blocks while the GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download). Two hardening changes on top of the existing "skip balance while syncing" guard: - Hysteresis: keep the low-impact sync profile (and balance suppression) for a short settle window after catching up, so a large-wallet scan can't immediately re-starve connection and bounce the node back into syncing. Armed only on the syncing→caught-up edge, so a wallet synced from the start is never throttled at connect (effectivelySyncing()). - Adaptive balance cadence: time each z_gettotalbalance scan and require the next poll to wait at least (cost / 10%), so balance scanning never occupies more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations (send/shield) force the next poll through so the user's own action updates the balance immediately (balanceRefreshDue()). getblockchaininfo keeps its normal cadence throughout, so sync progress stays live. Build + test_phase4 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -919,7 +919,9 @@ void App::update()
|
|||||||
|
|
||||||
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
|
// Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to
|
||||||
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
|
// a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy).
|
||||||
if (state_.sync.syncing != refresh_policy_syncing_) {
|
// effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal
|
||||||
|
// per-tab cadence once that window elapses.
|
||||||
|
if (effectivelySyncing() != refresh_policy_syncing_) {
|
||||||
applyRefreshPolicy(current_page_);
|
applyRefreshPolicy(current_page_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/app.h
10
src/app.h
@@ -1102,6 +1102,14 @@ private:
|
|||||||
bool daemon_start_error_shown_ = false;
|
bool daemon_start_error_shown_ = false;
|
||||||
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
||||||
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
||||||
|
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
|
||||||
|
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
|
||||||
|
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
|
||||||
|
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
|
||||||
|
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
|
||||||
|
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
|
||||||
|
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
|
||||||
|
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
|
||||||
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
||||||
std::uint64_t clipboard_secret_hash_ = 0;
|
std::uint64_t clipboard_secret_hash_ = 0;
|
||||||
double clipboard_clear_deadline_ = 0.0;
|
double clipboard_clear_deadline_ = 0.0;
|
||||||
@@ -1439,6 +1447,8 @@ private:
|
|||||||
void refreshPrice();
|
void refreshPrice();
|
||||||
void refreshWalletEncryptionState();
|
void refreshWalletEncryptionState();
|
||||||
void applyRefreshPolicy(ui::NavPage page);
|
void applyRefreshPolicy(ui::NavPage page);
|
||||||
|
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
|
||||||
|
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
|
||||||
bool currentPageNeedsWalletDataRefresh() const;
|
bool currentPageNeedsWalletDataRefresh() const;
|
||||||
bool shouldRunWalletTransactionRefresh() const;
|
bool shouldRunWalletTransactionRefresh() const;
|
||||||
bool shouldRefreshTransactions() const;
|
bool shouldRefreshTransactions() const;
|
||||||
|
|||||||
@@ -837,13 +837,39 @@ void App::applyRefreshPolicy(ui::NavPage page)
|
|||||||
// While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so
|
// While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so
|
||||||
// the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block
|
// the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block
|
||||||
// transaction scans / balance polls slow block connection). This makes every tab sync as fast
|
// transaction scans / balance polls slow block connection). This makes every tab sync as fast
|
||||||
// as the Console tab does today. Reverts to the per-tab profile once sync finishes.
|
// as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching
|
||||||
refresh_policy_syncing_ = state_.sync.syncing;
|
// up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the
|
||||||
|
// node back into "syncing". Reverts to the per-tab profile once the settle window passes.
|
||||||
|
refresh_policy_syncing_ = effectivelySyncing();
|
||||||
network_refresh_.setIntervals(refresh_policy_syncing_
|
network_refresh_.setIntervals(refresh_policy_syncing_
|
||||||
? services::RefreshScheduler::kSyncProfile
|
? services::RefreshScheduler::kSyncProfile
|
||||||
: getIntervalsForPage(page));
|
: getIntervalsForPage(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True while the node is behind, and for a short settle window after it first catches up. The settle
|
||||||
|
// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that
|
||||||
|
// was synced from the start is never throttled at connect — only a node that just finished catching up.
|
||||||
|
bool App::effectivelySyncing() const
|
||||||
|
{
|
||||||
|
if (state_.sync.syncing) return true;
|
||||||
|
if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up
|
||||||
|
return std::time(nullptr) < sync_settle_until_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since
|
||||||
|
// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A
|
||||||
|
// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s
|
||||||
|
// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for
|
||||||
|
// block connection. A wallet mutation bypasses this via force_balance_refresh_.
|
||||||
|
bool App::balanceRefreshDue() const
|
||||||
|
{
|
||||||
|
constexpr double kBalanceDutyCycle = 0.10;
|
||||||
|
if (state_.last_balance_update == 0) return true; // never fetched
|
||||||
|
if (last_balance_scan_ms_ <= 0.0) return true; // no cost measured yet
|
||||||
|
const double minInterval = (last_balance_scan_ms_ / 1000.0) / kBalanceDutyCycle;
|
||||||
|
return std::difftime(std::time(nullptr), state_.last_balance_update) >= minInterval;
|
||||||
|
}
|
||||||
|
|
||||||
bool App::currentPageNeedsWalletDataRefresh() const
|
bool App::currentPageNeedsWalletDataRefresh() const
|
||||||
{
|
{
|
||||||
using NP = ui::NavPage;
|
using NP = ui::NavPage;
|
||||||
@@ -1666,9 +1692,15 @@ void App::refreshCoreData()
|
|||||||
? fast_rpc_.get() : rpc_.get();
|
? fast_rpc_.get() : rpc_.get();
|
||||||
if (!w || !rpc) return;
|
if (!w || !rpc) return;
|
||||||
ui::NavPage tracePage = current_page_;
|
ui::NavPage tracePage = current_page_;
|
||||||
// Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock +
|
// Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main).
|
||||||
// cs_main). Captured on the main thread to avoid reading state_ off the worker thread.
|
// Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block
|
||||||
const bool includeBalance = !state_.sync.syncing;
|
// connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a
|
||||||
|
// large shielded wallet backs off automatically instead of re-scanning every couple of seconds.
|
||||||
|
// A wallet mutation (send/shield) forces the next poll through so the user's own action updates the
|
||||||
|
// balance immediately. Captured on the main thread to avoid reading state_ off the worker thread.
|
||||||
|
const bool includeBalance = !effectivelySyncing() &&
|
||||||
|
(force_balance_refresh_ || balanceRefreshDue());
|
||||||
|
if (includeBalance) force_balance_refresh_ = false;
|
||||||
|
|
||||||
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
|
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
|
||||||
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
|
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
|
||||||
@@ -1678,6 +1710,19 @@ void App::refreshCoreData()
|
|||||||
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
|
||||||
applyPendingSendBalanceDeltas(true);
|
applyPendingSendBalanceDeltas(true);
|
||||||
|
|
||||||
|
// Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's
|
||||||
|
// cost (0 when balance was skipped), and arm the settle window only on the
|
||||||
|
// syncing→caught-up edge so a wallet synced from the start is never throttled at connect.
|
||||||
|
if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs;
|
||||||
|
const bool nowSyncing = state_.sync.syncing;
|
||||||
|
if (nowSyncing) {
|
||||||
|
sync_settle_until_ = 0;
|
||||||
|
} else if (was_core_syncing_) {
|
||||||
|
constexpr double kSyncSettleSeconds = 8.0;
|
||||||
|
sync_settle_until_ = std::time(nullptr) + static_cast<std::time_t>(kSyncSettleSeconds);
|
||||||
|
}
|
||||||
|
was_core_syncing_ = nowSyncing;
|
||||||
|
|
||||||
// Mid-session connection-loss detection. During normal operation, both core
|
// Mid-session connection-loss detection. During normal operation, both core
|
||||||
// RPCs failing together means the daemon connection is dead (a busy daemon
|
// RPCs failing together means the daemon connection is dead (a busy daemon
|
||||||
// fails them individually, not both at once). Warmup is excluded — both fail
|
// fails them individually, not both at once). Warmup is excluded — both fail
|
||||||
@@ -5300,6 +5345,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
// Force transaction list refresh so the sent tx appears immediately
|
// Force transaction list refresh so the sent tx appears immediately
|
||||||
transactions_dirty_ = true;
|
transactions_dirty_ = true;
|
||||||
last_tx_block_height_ = -1;
|
last_tx_block_height_ = -1;
|
||||||
|
force_balance_refresh_ = true; // the user's own send must update the balance now, past the throttle
|
||||||
network_refresh_.markWalletMutationRefresh();
|
network_refresh_.markWalletMutationRefresh();
|
||||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||||
// broadcast asynchronously by the daemon. Defer the user-facing
|
// broadcast asynchronously by the daemon. Defer the user-facing
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -294,8 +295,13 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
|||||||
json blockInfo;
|
json blockInfo;
|
||||||
bool balanceOk = false;
|
bool balanceOk = false;
|
||||||
bool blockOk = false;
|
bool blockOk = false;
|
||||||
|
double balanceScanMs = 0.0;
|
||||||
|
|
||||||
if (includeBalance) {
|
if (includeBalance) {
|
||||||
|
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
|
||||||
|
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
|
||||||
|
// (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
|
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.call("z_gettotalbalance", json::array({0}));
|
||||||
balanceOk = true;
|
balanceOk = true;
|
||||||
@@ -305,6 +311,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
|||||||
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
|
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
|
||||||
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
|
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
|
balanceScanMs = std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now() - balanceStart).count();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -314,7 +322,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
|||||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||||
|
result.balanceScanMs = balanceScanMs;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public:
|
|||||||
std::optional<double> verificationProgress;
|
std::optional<double> verificationProgress;
|
||||||
std::optional<int> longestChain;
|
std::optional<int> longestChain;
|
||||||
std::optional<int> notarized;
|
std::optional<int> notarized;
|
||||||
|
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||||
};
|
};
|
||||||
|
|
||||||
struct MiningRefreshResult {
|
struct MiningRefreshResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user