perf(history): scan shielded history in bounded passes, not every block
Every block re-ran z_listreceivedbyaddress for every z-address (O(wallet) each, under cs_main) and a whole-wallet z_listsentbyaddress. On a 40-address wallet that was ~80% of wall-clock. The history now advances in passes: - an address is rescanned when it is stale by a number of blocks scaled to its measured cost (8 to 500), never-scanned and hinted addresses first (a hint: z_listunspent shows a note the history doesn't); - z_listsentbyaddress covers only the blocks since the last good sweep (+100 reorg margin); the whole-wallet sweep runs once per connect, with a 10 min / 1 h / 6 h backoff after real failures; - listtransactions pages are sized from last time, and an unchanged tip reuses the transparent rows; - found-but-unenriched txids carry over as a backlog; only -5 is a definitive "not ours" for z_viewtransaction; - between blocks, a cheap recent poll plus a one-address probe. Duty model for a 40-address wallet at 10 s per scan: ~9% instead of ~80%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the transaction history refresh may scan what. The history comes from three wallet-wide RPCs, each
|
||||
// holding the daemon's cs_main for its whole run (seconds per call on a large wallet): listtransactions,
|
||||
// z_listreceivedbyaddress (once per z-address) and z_listsentbyaddress. This decides, per refresh cycle,
|
||||
// which of them run, so the cost stays bounded and every pass over the z-addresses finishes. Pure and
|
||||
// main-thread only; App::refreshTransactionData() feeds it and the test target drives it directly.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr int kHistoryBlockSeconds = 36; // -ac_blocktime=36
|
||||
constexpr double kHistoryScanDuty = 0.10; // the same 10% as the other scan throttles
|
||||
constexpr int kHistoryMinStaleBlocks = 8; // re-scan an address at most every 8 blocks...
|
||||
constexpr int kHistoryMaxStaleBlocks = 500; // ...and at least every 500 (a safety net)
|
||||
constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99), plus tip drift
|
||||
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
|
||||
constexpr int kTransparentPageMin = 1000;
|
||||
constexpr int kTransparentPageMax = 5000;
|
||||
constexpr int kStallCyclesBeforeGating = 1;
|
||||
|
||||
// Blocks after which an address's last receive scan counts as stale and a new routine pass is due: the
|
||||
// pass then costs about kHistoryScanDuty of wall-clock (one z_listreceivedbyaddress of perAddressScanMs per
|
||||
// address, spread over that many blocks), within [kHistoryMinStaleBlocks, kHistoryMaxStaleBlocks].
|
||||
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs);
|
||||
|
||||
// Running cost estimate: a higher sample replaces it at once, a lower one pulls it down 25%. A sample of 0
|
||||
// or less (nothing measured) leaves it as it is.
|
||||
double updateScanCostEstimateMs(double previousMs, double sampleMs);
|
||||
|
||||
// listtransactions rows to ask for in the first page: last time's count plus headroom, so most wallets
|
||||
// get every row in one call instead of re-paging the whole history in 1000-row pages.
|
||||
int transparentPageSize(std::size_t previousRowCount);
|
||||
|
||||
// z_listsentbyaddress: the whole wallet when there's no usable watermark (none yet, or it is above the
|
||||
// tip after a reorg), else only txs at most (tip - fromHeight) + kSentSweepReorgMarginBlocks deep.
|
||||
bool sentSweepIsFull(int tip, int fromHeight);
|
||||
// [z, 0] for the full sweep, [z, 0, 2, maxDepth] for the window: JSON integers, and never 3 params (the
|
||||
// daemon rejects that count).
|
||||
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight);
|
||||
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
|
||||
long fullSentSweepRetryDelaySec(int consecutiveFailures);
|
||||
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
|
||||
// an error answer, anything unclassified), not when the call never ran (held back, aborted, not listening).
|
||||
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
|
||||
|
||||
// The address scanned longest ago (never-scanned first), for the between-blocks probe.
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights);
|
||||
|
||||
// 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
|
||||
// re-pass (addresses gone stale) runs only as the scan throttle allows.
|
||||
class ShieldedHistoryPass {
|
||||
public:
|
||||
struct Work {
|
||||
std::size_t total = 0; // z-addresses
|
||||
std::size_t missing = 0; // never scanned
|
||||
std::size_t pending = 0; // still to scan in this pass (or in the pass that is due)
|
||||
bool explicitWork = false; // may skip the scan throttle
|
||||
bool routineWork = false; // waits for the scan throttle
|
||||
};
|
||||
|
||||
Work evaluate(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, int staleAfter, bool initialComplete) const;
|
||||
// Start a pass if one is due and allowed (routine passes need routineAllowed, unless the first load
|
||||
// isn't complete yet). Clamps an active pass's floor after a reorg below it.
|
||||
void begin(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
int staleAfter, bool initialComplete, bool routineAllowed);
|
||||
// The floor a cycle scans up to: -1 means only never-scanned (and priority) addresses, which is all an
|
||||
// explicit-only cycle may do while a routine pass is active.
|
||||
int scanFloor(int tip, bool routineAllowed) const;
|
||||
// After a cycle's heights are merged: count stalls (scans attempted, none succeeded), end the pass once
|
||||
// no address is missing or below its floor.
|
||||
void finish(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
std::size_t attempted, std::size_t succeeded);
|
||||
void reset();
|
||||
|
||||
bool active() const { return active_; }
|
||||
bool explicitPass() const { return explicit_; }
|
||||
int startHeight() const { return startHeight_; }
|
||||
// Scans keep failing: explicit work is treated as routine (gated) until one succeeds again.
|
||||
bool stalled() const { return stalledCycles_ >= kStallCyclesBeforeGating; }
|
||||
|
||||
private:
|
||||
int floorAt(int tip) const;
|
||||
|
||||
bool active_ = false;
|
||||
bool explicit_ = false;
|
||||
int startHeight_ = -1;
|
||||
int stalledCycles_ = 0;
|
||||
};
|
||||
|
||||
enum class TxCycle {
|
||||
None,
|
||||
Full, // listtransactions, receive scans, the sent sweep, enrichment
|
||||
Light, // listtransactions and enrichment only: no wallet-wide z-scans
|
||||
Recent, // the between-blocks poll: recent listtransactions rows
|
||||
RecentWithProbe, // ...plus one receive scan of the address scanned longest ago
|
||||
};
|
||||
|
||||
struct TxCycleInputs {
|
||||
bool neverFetched = false; // no history loaded yet this session
|
||||
bool newTip = false; // the tip moved since the last history apply
|
||||
bool dirty = false; // something changed the wallet (a send, an import, ...)
|
||||
bool sendTracking = false; // tracked sends still waiting for their view
|
||||
bool explicitScanWork = false; // ShieldedHistoryPass::Work::explicitWork
|
||||
bool routineScanWork = false; // ShieldedHistoryPass::Work::routineWork
|
||||
bool gateOpen = false; // the scan throttle allows a wallet-wide scan now
|
||||
bool syncing = false; // the node is catching up (or settling after it)
|
||||
bool heightKnown = false; // the tip height is known
|
||||
bool recentDue = false; // the between-blocks poll is due on a wallet page
|
||||
};
|
||||
|
||||
// Only explicit work skips the throttle with scans; sends and other mutations get rows now through Light
|
||||
// cycles; nothing scans while syncing or before the tip is known. Tracking alone needs a new tip for a
|
||||
// Light cycle, so it can't loop them at one tip.
|
||||
TxCycle decideTxCycle(const TxCycleInputs& in);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user