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:
@@ -81,6 +81,23 @@ std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string ProbeRotation::next(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights)
|
||||
{
|
||||
std::unordered_set<std::string> listed(addresses.begin(), addresses.end());
|
||||
for (auto it = left_.begin(); it != left_.end();) {
|
||||
if (listed.count(*it)) ++it;
|
||||
else it = left_.erase(it);
|
||||
}
|
||||
if (left_.empty()) left_ = std::move(listed);
|
||||
std::vector<std::string> left;
|
||||
for (const auto& address : addresses) {
|
||||
if (left_.count(address)) left.push_back(address);
|
||||
}
|
||||
if (left.empty()) return {};
|
||||
return left[oldestScannedAddressIndex(left, heights)];
|
||||
}
|
||||
|
||||
int ShieldedHistoryPass::floorAt(int tip) const
|
||||
{
|
||||
return tip > 0 ? std::min(startHeight_, tip) : startHeight_;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -61,6 +62,22 @@ bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights);
|
||||
|
||||
// The between-blocks probe scans one address per run. After what it watches changed (the mempool, a wallet
|
||||
// mutation) it isn't done until it has probed every z-address once: the change may be any of them. Tracks
|
||||
// which are left, by address (heights can't tell: a rotation at one tip leaves them all equal).
|
||||
class ProbeRotation {
|
||||
public:
|
||||
// The next address to probe: the oldest-scanned of those left. Drops addresses no longer listed, and
|
||||
// starts a new rotation when none are left. Empty when there are no addresses.
|
||||
std::string next(const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights);
|
||||
void probed(const std::string& address) { left_.erase(address); }
|
||||
bool complete() const { return left_.empty(); }
|
||||
void reset() { left_.clear(); }
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> left_;
|
||||
};
|
||||
|
||||
// One pass of receive scans over every z-address. An address is done once it was scanned at or above the
|
||||
// pass's start height (its floor), so a pass ends after ceil(A / budget) cycles however the tip moves. An
|
||||
// explicit pass (the first load of a wallet, or after an invalidation) may run back to back; a routine
|
||||
|
||||
@@ -343,6 +343,25 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
// The mempool's size: with the tip hash and the wallet's mutation epoch, what tells the caller whether a
|
||||
// wallet scan could see anything new. getmempoolinfo takes no cs_main. Failing leaves it unknown, which
|
||||
// counts as changed.
|
||||
bool mempoolFetched = false, mempoolOk = false;
|
||||
std::int64_t mempoolTxs = 0, mempoolBytes = 0;
|
||||
if (plan.fetchMempool && blockOk) {
|
||||
mempoolFetched = true;
|
||||
try {
|
||||
json pool = rpc.call("getmempoolinfo", json::array());
|
||||
if (pool.is_object()) {
|
||||
mempoolOk = true;
|
||||
if (pool.contains("size") && pool["size"].is_number_integer()) mempoolTxs = pool["size"].get<std::int64_t>();
|
||||
if (pool.contains("bytes") && pool["bytes"].is_number_integer()) mempoolBytes = pool["bytes"].get<std::int64_t>();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getmempoolinfo error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
|
||||
// the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
|
||||
// anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
|
||||
@@ -400,6 +419,10 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
result.balanceErrKind = balanceErrKind;
|
||||
result.balanceAttempted = balanceAttempted;
|
||||
result.spendableQueried = spendableQueried;
|
||||
result.mempoolFetched = mempoolFetched;
|
||||
result.mempoolOk = mempoolOk;
|
||||
result.mempoolTxs = mempoolTxs;
|
||||
result.mempoolBytes = mempoolBytes;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -773,6 +796,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -799,11 +823,12 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try { // display total (minconf=0, includes pending change)
|
||||
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||
if (!total.is_null()) info.balance = total.get<double>();
|
||||
} catch (...) {}
|
||||
} catch (...) { result.balancesOk = false; }
|
||||
try { // spendable (minconf=1); degrade to the display value on old daemons
|
||||
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
|
||||
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||
@@ -817,6 +842,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -824,6 +850,8 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||
result.balancesOk = false;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
@@ -1114,6 +1142,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
result.transparentChainOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
@@ -1155,6 +1184,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
};
|
||||
@@ -1243,6 +1273,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
|
||||
result.replyTimedOut |= result.sentSweepErrKind == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||
}
|
||||
result.sentSweepMs = msSince(sweepStart);
|
||||
@@ -1447,6 +1478,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
result.scanMs = msSince(scanStart);
|
||||
result.rpcError = transactionRpcError;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1499,6 +1531,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
result.rpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
@@ -1540,6 +1574,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
std::chrono::steady_clock::now() - callStart).count();
|
||||
} catch (const std::exception& e) {
|
||||
++result.shieldedAddressesScanned;
|
||||
result.rpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ public:
|
||||
bool balanceAttempted = false;
|
||||
bool spendableQueried = false; // the minconf=1 call was made
|
||||
std::optional<std::int64_t> totalBalanceZat; // the minconf=0 "total" in zatoshis, exact
|
||||
// getmempoolinfo (CoreRefreshPlan::fetchMempool): a change signal that takes no cs_main.
|
||||
bool mempoolFetched = false;
|
||||
bool mempoolOk = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
};
|
||||
|
||||
// What one Core refresh fetches. The minconf=1 (spendable) z_gettotalbalance is a second whole-wallet
|
||||
@@ -141,6 +146,7 @@ public:
|
||||
bool includeBalance = true;
|
||||
bool needSpendable = true;
|
||||
std::optional<std::int64_t> prevTotalZat;
|
||||
bool fetchMempool = false; // add getmempoolinfo after a successful getblockchaininfo
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -200,6 +206,8 @@ public:
|
||||
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
|
||||
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
|
||||
std::vector<UnspentNoteLite> unspentNotes;
|
||||
bool balancesOk = true; // false: a balance call failed, the per-address balances may be stale
|
||||
bool replyTimedOut = false; // a call hit the reply timeout (the daemon kept running it)
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
@@ -283,6 +291,8 @@ public:
|
||||
// Txids found but not enriched (over the per-cycle cap, or the view failed transiently): the caller
|
||||
// keeps them and feeds them back as enrichBacklogTxids.
|
||||
std::unordered_set<std::string> unenrichedTxids;
|
||||
bool rpcError = false; // a wallet-wide call failed
|
||||
bool replyTimedOut = false; // ...with a reply timeout (the daemon kept running it)
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
@@ -465,6 +475,7 @@ public:
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
|
||||
void retrySoon(Timer timer, float seconds) { scheduler_.retrySoon(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
|
||||
@@ -92,6 +92,12 @@ void RefreshScheduler::deferFor(Timer timer, float seconds)
|
||||
holdRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::retrySoon(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, interval(timer) - std::max(0.0f, seconds));
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
|
||||
@@ -62,6 +62,9 @@ public:
|
||||
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
|
||||
// the page interval was shorter than the delay (every frame on Overview/Send).
|
||||
void deferFor(Timer timer, float seconds);
|
||||
// Due again `seconds` from now (a scan the governor held back asks again soon). Unlike deferFor, never
|
||||
// due on a 0-interval timer (the sync profile's), so a held-back scan can't fire mid-sync.
|
||||
void retrySoon(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_scan_governor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground)
|
||||
{
|
||||
ScanVisibility v;
|
||||
if (windowBackground) return v;
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
v.balance = v.addresses = v.history = true; // totals, per-address balances, recent activity
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
v.addresses = v.history = true; // per-address balances, recent sends / receives
|
||||
break;
|
||||
case NP::History:
|
||||
v.history = true;
|
||||
break;
|
||||
case NP::Market:
|
||||
v.balance = true; // portfolio value
|
||||
break;
|
||||
case NP::Chat:
|
||||
v.chat = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
|
||||
{
|
||||
const double floorSec = autoShieldArmed ? 120.0 : 600.0;
|
||||
return std::max(floorSec, 50.0 * std::max(0.0, emaCostSec));
|
||||
}
|
||||
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
{
|
||||
if (!in.allowBalance || in.syncing) return BalancePollDecision::Skip;
|
||||
if (in.forced || in.neverFetched) return BalancePollDecision::Explicit;
|
||||
if (!in.changed) return BalancePollDecision::Skip;
|
||||
if (in.visible) {
|
||||
// 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;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
if (in.sinceLastScanSec < hiddenBalanceIntervalSec(in.emaCostSec, in.autoShieldArmed))
|
||||
return BalancePollDecision::Skip;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::budgetForTipLag(double base, int blocksBehind)
|
||||
{
|
||||
if (blocksBehind <= 0) return base;
|
||||
if (blocksBehind == 1) return base * 2.0 / 3.0;
|
||||
return base / 3.0;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::signalsMatch(ScanStream stream, const ScanSignals& current) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return false;
|
||||
const unsigned mask = cfg_.streams[static_cast<std::size_t>(stream)].signalMask;
|
||||
if (mask & kScanSignalTip) {
|
||||
if (current.tipHash.empty() || st.basis.tipHash.empty() || current.tipHash != st.basis.tipHash) return false;
|
||||
}
|
||||
if (mask & kScanSignalMempool) {
|
||||
if (!current.mempoolKnown || !st.basis.mempoolKnown) return false;
|
||||
if (current.mempoolTxs != st.basis.mempoolTxs || current.mempoolBytes != st.basis.mempoolBytes) return false;
|
||||
}
|
||||
if (mask & kScanSignalEpoch) {
|
||||
if (current.walletEpoch != st.basis.walletEpoch) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::needed(ScanStream stream, const ScanSignals& current, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return true;
|
||||
if (!signalsMatch(stream, current)) return true;
|
||||
const double maxAge = cfg_.streams[static_cast<std::size_t>(stream)].maxAgeSec;
|
||||
return maxAge > 0.0 && now - st.basisAt >= maxAge;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::floorSec(const Stream& stream) const
|
||||
{
|
||||
return std::max(stream.askVisible ? cfg_.visibleFloorSec : cfg_.hiddenFloorSec,
|
||||
cfg_.floorCostMultiple * stream.emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
ScanStream WalletScanGovernor::bestWaiter(double now) const
|
||||
{
|
||||
const bool quiet = now < quietUntil_;
|
||||
ScanStream best = ScanStream::Count;
|
||||
double bestKey = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t i = 0; i < streams_.size(); ++i) {
|
||||
const Stream& st = streams_[i];
|
||||
if (st.waitingSince < 0.0) continue;
|
||||
if (now - st.lastAskAt > cfg_.waiterTtlSec) continue; // stopped asking
|
||||
if (quiet && now - st.waitingSince < floorSec(st)) continue; // during quiet, only via its floor
|
||||
const double key = st.waitingSince - (st.askVisible ? cfg_.visibleHeadStartSec : 0.0);
|
||||
if (key < bestKey) {
|
||||
bestKey = key;
|
||||
best = static_cast<ScanStream>(i);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = stream;
|
||||
t.kind = kind;
|
||||
t.id = ++nextId_;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = now;
|
||||
t.servesRoutine = servesRoutine;
|
||||
t.basis = current;
|
||||
live_.push_back(Live{t.id, stream, kind, now, servesRoutine});
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::ticketFor(const Live& live) const
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = live.stream;
|
||||
t.kind = live.kind;
|
||||
t.id = live.id;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = live.dispatchedAt;
|
||||
t.servesRoutine = live.servesRoutine;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
|
||||
st.lastAskAt = now;
|
||||
st.askVisible = visible;
|
||||
pruneLeases(now);
|
||||
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
|
||||
if (bestWaiter(now) != stream) return {};
|
||||
ScanTicket t = issue(stream, ScanKind::Routine, current, now, true);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::beginExplicit(ScanStream stream, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t = issue(stream, ScanKind::Explicit, current, now, servesRoutine);
|
||||
if (servesRoutine) {
|
||||
Stream& st = at(stream);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0; // served
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::eraseLive(std::uint64_t id)
|
||||
{
|
||||
live_.erase(std::remove_if(live_.begin(), live_.end(), [id](const Live& l) { return l.id == id; }), live_.end());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// A ticket that sent nothing gives back the wait it ended (keeping a wait begun since, if older).
|
||||
void restoreWait(double& waitingSince, double waitedSince)
|
||||
{
|
||||
if (waitedSince < 0.0) return;
|
||||
waitingSince = waitingSince < 0.0 ? waitedSince : std::min(waitingSince, waitedSince);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void WalletScanGovernor::cancel(const ScanTicket& ticket)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return;
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return;
|
||||
eraseLive(ticket.id);
|
||||
restoreWait(at(ticket.stream).waitingSince, ticket.waitedSince);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return; // from before a reset()
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return; // already completed (or dropped as an orphan)
|
||||
eraseLive(ticket.id);
|
||||
Stream& st = at(ticket.stream);
|
||||
if (outcome == ScanOutcome::Skipped) { // nothing was sent: no charge, and it keeps its place in line
|
||||
restoreWait(st.waitingSince, ticket.waitedSince);
|
||||
return;
|
||||
}
|
||||
st.lastScanAt = std::max(st.lastScanAt, ticket.dispatchedAt);
|
||||
double ms = std::max(0.0, costMs);
|
||||
if (outcome == ScanOutcome::TimedOut && ticket.servesRoutine) ms = std::max(ms, st.emaCostMs);
|
||||
if (ms > 0.0) {
|
||||
// Quiet long enough that this scan was `budget` of the time since it started.
|
||||
const double debt = ms / 1000.0 * (1.0 / budgetForTipLag(cfg_.budget, tipLag_) - 1.0);
|
||||
const double cap = now + (ticket.kind == ScanKind::Explicit ? cfg_.explicitDebtCapSec : cfg_.maxQuietSec);
|
||||
quietUntil_ = std::max(quietUntil_, std::min(std::max(quietUntil_, now) + debt, cap));
|
||||
}
|
||||
if (!ticket.servesRoutine) return; // charged; the routine scans' cost and basis are theirs
|
||||
if (outcome == ScanOutcome::Ok || outcome == ScanOutcome::Partial || outcome == ScanOutcome::TimedOut) {
|
||||
// Rises at once, decays slowly: a scan that got expensive is spaced out right away.
|
||||
st.emaCostMs = (st.emaCostMs <= 0.0 || ms > st.emaCostMs) ? ms : 0.7 * st.emaCostMs + 0.3 * ms;
|
||||
}
|
||||
if (outcome == ScanOutcome::Failed || outcome == ScanOutcome::TimedOut) {
|
||||
if (st.pendingValid) st.pendingPoisoned = true;
|
||||
} else if (outcome == ScanOutcome::Partial) {
|
||||
if (!st.pendingValid) {
|
||||
st.pending = ticket.basis;
|
||||
st.pendingAt = ticket.dispatchedAt;
|
||||
st.pendingValid = true;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
} else if (outcome == ScanOutcome::Ok) {
|
||||
// A pass that started at the pending basis is current for that basis (the earliest), unless one of
|
||||
// its dispatches failed.
|
||||
if (!(st.pendingValid && st.pendingPoisoned)) {
|
||||
st.basis = st.pendingValid ? st.pending : ticket.basis;
|
||||
st.basisAt = st.pendingValid ? st.pendingAt : ticket.dispatchedAt;
|
||||
st.basisValid = true;
|
||||
}
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WalletScanGovernor::dropOrphans(ScanStream stream, double now)
|
||||
{
|
||||
std::vector<Live> orphans;
|
||||
for (const auto& l : live_) {
|
||||
if (l.stream == stream) orphans.push_back(l);
|
||||
}
|
||||
for (const auto& l : orphans) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::pruneLeases(double now)
|
||||
{
|
||||
std::vector<Live> expired;
|
||||
for (const auto& l : live_) {
|
||||
const double lease = std::max(cfg_.minLeaseSec, 4.0 * at(l.stream).emaCostMs / 1000.0);
|
||||
if (now - l.dispatchedAt > lease) expired.push_back(l);
|
||||
}
|
||||
for (const auto& l : expired) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::invalidate(ScanStream stream)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
st.basisValid = false;
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::reset()
|
||||
{
|
||||
const std::uint64_t nextGeneration = generation_ + 1;
|
||||
const std::uint64_t nextId = nextId_;
|
||||
*this = WalletScanGovernor(cfg_);
|
||||
generation_ = nextGeneration;
|
||||
nextId_ = nextId;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::inFlight(ScanStream stream) const
|
||||
{
|
||||
return std::any_of(live_.begin(), live_.end(), [stream](const Live& l) { return l.stream == stream; });
|
||||
}
|
||||
|
||||
double WalletScanGovernor::secondsSinceScan(ScanStream stream, double now) const
|
||||
{
|
||||
const double at_ = at(stream).lastScanAt;
|
||||
return at_ < 0.0 ? 1e9 : now - at_;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::waiting(ScanStream stream, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
return st.waitingSince >= 0.0 && now - st.lastAskAt <= cfg_.waitLapseSec;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::heldWithinFloor(ScanStream stream, double now) const
|
||||
{
|
||||
if (!waiting(stream, now)) return false;
|
||||
const Stream& st = at(stream);
|
||||
return now - st.waitingSince < floorSec(st) + st.emaCostMs / 1000.0;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::visibleFloorSecFor(ScanStream stream) const
|
||||
{
|
||||
return std::max(cfg_.visibleFloorSec, cfg_.floorCostMultiple * at(stream).emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,214 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// One budget for every wallet-wide scan the GUI sends on its own: the balance (z_gettotalbalance), the
|
||||
// address scan (z_listunspent + listunspent), the history (z_listreceivedbyaddress, z_listsentbyaddress) and
|
||||
// the between-blocks probe. Each holds dragonxd's cs_main for its whole run, seconds per call on a large
|
||||
// wallet, which starves block connection. They used to be throttled one by one at 10% each, which adds up
|
||||
// to 30-40% with all of them due. Here: one scan in flight at a time across both RPC lanes, and one shared
|
||||
// quiet clock that keeps the total near `budget` of wall-clock. A stream that keeps asking is served FIFO
|
||||
// (visible ones a little first), and a floor scaled to its own cost keeps any stream from starving.
|
||||
// Explicit needs (the user's own send, a dirty set, a never-loaded list) are never refused, but are charged.
|
||||
//
|
||||
// Each stream also keeps the signals it last scanned at (tip hash, mempool size, a wallet-mutation epoch):
|
||||
// a routine scan is only needed when they changed, or after the stream's safety sweep.
|
||||
//
|
||||
// An explicit ticket that doesn't do the stream's routine work (a history cycle that only lists rows) is
|
||||
// charged to the quiet clock but leaves the stream's wait, cost estimate and basis alone (servesRoutine).
|
||||
//
|
||||
// Pure and main-thread only: every call takes `now` in seconds of a steady clock.
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class ScanStream : std::uint8_t { Balance, Addresses, History, RecentTx, ChatNotes, ChatFast, Adhoc, Count };
|
||||
enum class ScanKind : std::uint8_t { Routine, Explicit };
|
||||
enum class ScanOutcome : std::uint8_t {
|
||||
Ok, // applied: the stream's signals basis advances
|
||||
Partial, // applied, more dispatches needed before the basis may advance (a multi-cycle pass)
|
||||
Failed, // charged; the basis stays (and a pending pass is poisoned)
|
||||
TimedOut, // a reply timeout: charged at least the stream's usual cost (the daemon still runs it)
|
||||
Skipped, // admitted, but no scan was sent (node behind, busy, held back): not charged
|
||||
};
|
||||
enum : unsigned { kScanSignalTip = 1, kScanSignalMempool = 2, kScanSignalEpoch = 4, kScanSignalAll = 7 };
|
||||
|
||||
// What a scan's result depends on, read without cs_main: the tip (bestblockhash, fetched each Core cycle),
|
||||
// the mempool (getmempoolinfo takes only mempool.cs) and the App's wallet-mutation epoch.
|
||||
struct ScanSignals {
|
||||
std::string tipHash;
|
||||
bool mempoolKnown = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
std::uint64_t walletEpoch = 0;
|
||||
};
|
||||
|
||||
struct ScanTicket {
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t generation = 0;
|
||||
double dispatchedAt = 0.0;
|
||||
double waitedSince = -1.0; // when its wait began, if it ended one (cancel() / Skipped restore it)
|
||||
bool servesRoutine = true; // does the stream's routine work: ends its wait, feeds its cost and basis
|
||||
ScanSignals basis; // the signals at dispatch: what an Ok result is current for
|
||||
explicit operator bool() const { return id != 0; }
|
||||
};
|
||||
|
||||
// Which streams the current page shows (visible streams get a head start and a shorter starvation floor).
|
||||
struct ScanVisibility {
|
||||
bool balance = false;
|
||||
bool addresses = false;
|
||||
bool history = false;
|
||||
bool chat = false;
|
||||
};
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground);
|
||||
|
||||
// The balance part of a Core poll.
|
||||
enum class BalancePollDecision { Skip, AskGovernor, Explicit };
|
||||
struct BalancePollInputs {
|
||||
bool allowBalance = true; // false for the locked wallet's chain-state poll
|
||||
bool syncing = false; // the node is catching up (or settling after it)
|
||||
bool forced = false; // the user's own send
|
||||
bool neverFetched = false; // no balance yet this session
|
||||
bool changed = true; // the governor's change gate
|
||||
bool visible = true; // the page shows the aggregate balance
|
||||
bool autoShieldArmed = false; // client-side auto-shield runs from balance results
|
||||
double sinceLastScanSec = 1e9; // since the last balance scan that was sent
|
||||
double emaCostSec = 0.0;
|
||||
// Coming into view with an old balance (back to Overview, the window restored): one explicit poll
|
||||
// instead of a fresh wait behind the other streams. Only when the stream isn't already waiting and
|
||||
// both the value on screen and the last scan are older than the stream's visible floor.
|
||||
bool waiting = false; // the Balance stream is waiting for admission
|
||||
double shownAgeSec = 0.0; // age of the value on screen (last fetch, or last poll that confirmed it)
|
||||
double visibleFloorSec = 30.0; // WalletScanGovernor::visibleFloorSecFor(Balance)
|
||||
};
|
||||
// How often a page that doesn't show the balance still polls it: max(10 min, 50x its cost), or every 2 min
|
||||
// while client-side auto-shield needs it.
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed);
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in);
|
||||
|
||||
class WalletScanGovernor {
|
||||
public:
|
||||
struct StreamConfig {
|
||||
unsigned signalMask = kScanSignalAll; // which signals the change gate compares
|
||||
double maxAgeSec = 0.0; // safety sweep: needed this long after its basis (0 = never)
|
||||
};
|
||||
struct Config {
|
||||
double budget = 0.15; // share of wall-clock for all routine scans together
|
||||
double explicitDebtCapSec = 120; // an explicit scan never pushes the quiet clock past now + this
|
||||
double maxQuietSec = 900; // no scan pushes it past now + this (no runaway debt)
|
||||
double visibleFloorSec = 30; // a waiting visible stream is admitted after this, quiet or not...
|
||||
double hiddenFloorSec = 900; // ...a hidden one after this...
|
||||
double floorCostMultiple = 15; // ...or after 15x its own cost if longer (<= ~6% each from floors)
|
||||
double visibleHeadStartSec = 60; // visible waiters rank as if they had waited this much longer
|
||||
double waiterTtlSec = 15; // a stream that hasn't asked for this long isn't waiting
|
||||
double waitLapseSec = 60; // asking again after this long starts a new wait
|
||||
double minLeaseSec = 600; // a ticket older than max(this, 4x its cost) is presumed lost
|
||||
std::array<StreamConfig, static_cast<std::size_t>(ScanStream::Count)> streams{{
|
||||
{kScanSignalAll, 300}, // Balance
|
||||
{kScanSignalAll, 300}, // Addresses
|
||||
{kScanSignalTip | kScanSignalEpoch, 0}, // History
|
||||
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
|
||||
{kScanSignalAll, 60}, // ChatNotes
|
||||
{kScanSignalAll, 60}, // ChatFast
|
||||
{0, 0}, // Adhoc
|
||||
}};
|
||||
};
|
||||
|
||||
WalletScanGovernor() = default;
|
||||
explicit WalletScanGovernor(const Config& config) : cfg_(config) {}
|
||||
|
||||
// The budget while the node is behind: full at the tip, 2/3 one block behind, 1/3 two or more.
|
||||
static double budgetForTipLag(double base, int blocksBehind);
|
||||
|
||||
// A routine scan of this stream is needed: no basis yet, the signals changed, or the sweep is due.
|
||||
bool needed(ScanStream stream, const ScanSignals& current, double now) const;
|
||||
// The stream's basis matches these signals (masked). An unknown tip or mempool never matches.
|
||||
bool signalsMatch(ScanStream stream, const ScanSignals& current) const;
|
||||
|
||||
// Ask to run a routine scan now: a ticket when admitted, else an empty one (the stream keeps waiting
|
||||
// while it keeps asking). Admitted only when no scan is in flight and this stream is the best waiter.
|
||||
ScanTicket tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now);
|
||||
// An explicit need: never refused, charged (with capped debt). servesRoutine = false for one that
|
||||
// doesn't do the stream's routine work: it is charged, but the stream keeps waiting for that work and
|
||||
// its cost estimate and basis stay those of the routine scans.
|
||||
ScanTicket beginExplicit(ScanStream stream, const ScanSignals& current, double now, bool servesRoutine = true);
|
||||
// The scan wasn't sent after all (the job queue refused it): drop the ticket with no charge.
|
||||
void cancel(const ScanTicket& ticket);
|
||||
// The scan's result is in. `basis` may differ from the ticket's (e.g. what the worker saw).
|
||||
void complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now);
|
||||
// Tickets of this stream whose job is gone without a result: completed as timed out.
|
||||
void dropOrphans(ScanStream stream, double now);
|
||||
void setTipLag(int blocksBehind) { tipLag_ = blocksBehind > 0 ? blocksBehind : 0; }
|
||||
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
|
||||
void reset(); // a new connection: forget everything; old tickets complete as no-ops
|
||||
|
||||
bool inFlight(ScanStream stream) const;
|
||||
bool anyInFlight() const { return !live_.empty(); }
|
||||
double quietUntil() const { return quietUntil_; }
|
||||
double emaCostMs(ScanStream stream) const { return at(stream).emaCostMs; }
|
||||
// Since the dispatch of the last scan that was sent (cancelled and Skipped tickets don't count); 1e9 if none.
|
||||
double secondsSinceScan(ScanStream stream, double now) const;
|
||||
double currentBudget() const { return budgetForTipLag(cfg_.budget, tipLag_); }
|
||||
// The stream asked within the lapse window and hasn't been admitted yet.
|
||||
bool waiting(ScanStream stream, double now) const;
|
||||
// Waiting, and still within what the budget promises it: its floor plus one scan of its own. Past that,
|
||||
// or not asking, a stale value means something else is wrong.
|
||||
bool heldWithinFloor(ScanStream stream, double now) const;
|
||||
// The floor it would get as a visible waiter: max(visibleFloorSec, floorCostMultiple x its cost).
|
||||
double visibleFloorSecFor(ScanStream stream) const;
|
||||
|
||||
private:
|
||||
struct Stream {
|
||||
double waitingSince = -1.0;
|
||||
double lastAskAt = -1e18;
|
||||
bool askVisible = false;
|
||||
double emaCostMs = 0.0;
|
||||
double lastScanAt = -1.0; // dispatch time of the last scan that was sent
|
||||
bool basisValid = false;
|
||||
ScanSignals basis;
|
||||
double basisAt = 0.0;
|
||||
bool pendingValid = false;
|
||||
bool pendingPoisoned = false;
|
||||
ScanSignals pending;
|
||||
double pendingAt = 0.0;
|
||||
};
|
||||
struct Live {
|
||||
std::uint64_t id = 0;
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
double dispatchedAt = 0.0;
|
||||
bool servesRoutine = true;
|
||||
};
|
||||
|
||||
Stream& at(ScanStream stream) { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
const Stream& at(ScanStream stream) const { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
double floorSec(const Stream& stream) const;
|
||||
ScanStream bestWaiter(double now) const;
|
||||
ScanTicket issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now, bool servesRoutine);
|
||||
ScanTicket ticketFor(const Live& live) const;
|
||||
void eraseLive(std::uint64_t id);
|
||||
void pruneLeases(double now);
|
||||
|
||||
Config cfg_;
|
||||
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
|
||||
std::vector<Live> live_;
|
||||
double quietUntil_ = 0.0;
|
||||
int tipLag_ = 0;
|
||||
std::uint64_t nextId_ = 0;
|
||||
std::uint64_t generation_ = 1;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user