perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope

The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole-
wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now
runs only after the display call succeeded, and only when something is
unconfirmed or pending or the exact total moved: about 95% of polls make
one scan instead of two. A forced post-send balance can no longer be lost
to a refused enqueue, and pending-send debits aren't re-applied each tick.

z_getoperationstatus is filtered to our opids with its real argument form
([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered
call if a daemon refuses the filter. The Shield/Merge dialog's own polls
are filtered and session-guarded too, and finally reach Done; the dead
RPCClient wrappers are deleted.

The Merge/Consolidate scope scan becomes a tested state machine: one scan
at a time, failures latch behind a Retry button, only "node not ready"
retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s
calls are maintenance calls: every main-thread disconnect aborts one in
flight first, so a lifecycle action no longer freezes the UI behind it.
An aborted merge that was already sent says it may still go through.

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 4eed4283d6
commit 8b9a498eee
23 changed files with 1044 additions and 128 deletions
+58 -3
View File
@@ -15,6 +15,8 @@
#include <unordered_set>
#include <deque>
#include <condition_variable>
#include <cstdint>
#include <optional>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -432,6 +434,42 @@ public:
// richer pending-send path internally; this is for operations with no optimistic
// transaction row of their own.
void trackOperation(const std::string& opid);
// The connected daemon rejected the opid-filtered z_getoperationstatus, so polls use the unfiltered
// form (every retained op) for the rest of this connection.
bool opidFilterUnsupported() const { return opid_filter_unsupported_.load(std::memory_order_relaxed); }
// RAII around one long ("maintenance") rpc_ call made on a worker thread with a timeout above the
// default 30 s, e.g. a merge or a whole-wallet scan given 120 s. rpc_ holds its curl lock for the
// whole transfer and disconnect() takes the same lock, so these are counted for
// abortMaintenanceCalls().
class MaintenanceCallScope {
public:
explicit MaintenanceCallScope(App& app) : app_(app)
{
// Count first, then read the flag; abortMaintenanceCalls() sets the flag, then reads the count.
// Either this sees the abort and never sends, or the abort sees this call and cuts it off. (A
// call queued behind the one disconnect() waits on could otherwise take the curl lock next
// and hold disconnect() for its full 120 s.)
app_.maint_calls_.fetch_add(1);
if (app_.maint_abort_.load()) {
app_.maint_calls_.fetch_sub(1);
throw rpc::RpcTransportError(rpc::RpcFailureKind::Aborted, "RPC aborted: disconnecting");
}
}
~MaintenanceCallScope() { app_.maint_calls_.fetch_sub(1); }
MaintenanceCallScope(const MaintenanceCallScope&) = delete;
MaintenanceCallScope& operator=(const MaintenanceCallScope&) = delete;
private:
App& app_;
};
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
// 30 s, as before), so a user's own send is never cut off mid-call.
void abortMaintenanceCalls();
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
// full node that is past warmup, not rescanning and not busy.
bool walletScanReady() const;
// Force refresh
void refreshNow();
@@ -738,14 +776,18 @@ private:
// t_addresses for `fromAddress` and clamp its balance at >=0. A positive `signedAmount`
// restores a debit; a negative one applies it. When `includeAggregates` is set, adjust the
// private/transparent bucket (chosen by the address's leading 'z') and totalBalance with the
// same clamp. Shared by the three pending-send delta sites so they can't drift.
// same clamp. Shared by the three pending-send delta sites so they can't drift. `includeAddress`
// off leaves the per-address balances alone (the Core poll refreshes only the aggregates).
void applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
bool includeAggregates);
bool includeAggregates, bool includeAddress = true);
// Deliver a deferred z_sendmany result to its waiting UI callback once the opid
// reaches a terminal status. Returns true if a callback was registered (and fired).
bool invokeSendResultCallback(const std::string& opid, bool ok,
const std::string& result);
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
// Re-apply every pending send's debit on top of freshly fetched balances: the aggregates after a
// Core poll that carried a balance, the per-address balances after an address scan. Only onto
// fresh values, or a debit applied again on every tick would drain them.
void applyPendingSendBalanceDeltas(bool includeAggregateBalances, bool includeAddressBalances = true);
std::string transactionHistoryCacheWalletIdentity() const;
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
// Record the active wallet's cached metadata (balance, address count, identity, size) into the
@@ -1302,6 +1344,16 @@ private:
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
// Bumped with each forced-refresh request, so the poll that carried the forced balance clears the
// flag only if no newer request came in meanwhile (see requestForcedBalanceRefresh()).
std::uint32_t force_balance_seq_ = 0;
// The exact minconf=0 total of the last balance poll: the minconf=1 call is skipped while it holds
// still and nothing is pending (NetworkRefreshService::CoreRefreshPlan).
std::optional<std::int64_t> last_total_balance_zat_;
// Force the next balance poll through the throttle (the user's own send). Cleared by the Core poll
// that actually sent the forced balance call, not at dispatch, so a dispatch the job queue refuses
// (a Core job already in flight) or one that skips the balance (node behind or busy) keeps it.
void requestForcedBalanceRefresh() { force_balance_refresh_ = true; ++force_balance_seq_; }
// Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main:
// the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a
// large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and
@@ -1490,6 +1542,9 @@ private:
std::unordered_set<std::string> witness_seen_txids_;
int witness_total_txs_ = 0;
bool opid_poll_in_progress_ = false;
std::atomic<bool> opid_filter_unsupported_{false}; // see opidFilterUnsupported(); set on the worker
std::atomic<int> maint_calls_{0}; // maintenance calls in flight (MaintenanceCallScope)
std::atomic<bool> maint_abort_{false}; // set by abortMaintenanceCalls(), cleared on connect
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
int consecutive_core_failures_ = 0;