feat(wallet): pause routine wallet scans during a user-run consolidation

A manual consolidation's merges compete with the GUI's routine wallet scans for
the daemon's cs_main: the run won't fire the next merge while any scan is in
flight, and because each merge mutates the wallet it re-triggers the balance /
history scans, which then delay the following merge. The WalletScanGovernor
already serializes all of these one-at-a-time under a cs_main budget, so the fix
is to pause the heavy routine streams — not every RPC — while a user run owns the
budget.

WalletScanGovernor gains per-stream routine suppression (tryAdmit returns empty
for a paused stream, which keeps waiting and resumes the instant it is unpaused;
explicit needs via beginExplicit are never paused), plus a convenience that
pauses the balance / addresses / history / recent-tx group together. App::update
pauses that group while consolidation_.active() AND the run is user-started —
an auto (background) run is left gentle and untouched, and the cheap sync / peer
/ mempool / operation-status polls keep running so sync and disconnects stay
visible. The end-of-run forced balance refresh repaints once the run finishes.

Note: a manual run is bounded (≤ maxBatches per click, ~minutes), so the balance
and recent-tx views are only briefly stale — an incoming payment shows once the
run finishes or is stopped. Unit-tested (testWalletScanGovernor): a paused stream
is not admitted but keeps waiting, explicit bypasses it, unpausing re-admits, and
chat/Adhoc are never in the paused group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 14:22:53 -05:00
co-authored by Claude Opus 4.8
parent 9335e2bffb
commit d83169c963
4 changed files with 41 additions and 0 deletions
+5
View File
@@ -1594,6 +1594,11 @@ void App::update()
pumpConsolidation(); pumpConsolidation();
// Background auto-consolidation (settings toggle): gently trickle-merge notes when the wallet is idle. // Background auto-consolidation (settings toggle): gently trickle-merge notes when the wallet is idle.
pumpAutoConsolidation(); pumpAutoConsolidation();
// While a USER-started consolidation is running, pause the heavy routine wallet scans (balance /
// addresses / history / recent-tx) so its merges get more of the node's cs_main budget and finish
// sooner — the end-of-run refresh repaints. An auto (background) run stays gentle and is left alone, and
// explicit needs (the user's own send, a never-loaded list) still go through. Idempotent per frame.
scan_governor_.suppressRoutineWalletScans(consolidation_.active() && !auto_consolidate_run_);
// A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it // A Settings bootstrap that finished while its dialog wasn't drawn (clicked away, minimized): process it
// here so the connect and reconcile gates release and the post-bootstrap rescan is armed. // here so the connect and reconcile gates release and the post-bootstrap rescan is armed.
+3
View File
@@ -171,6 +171,9 @@ ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const S
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
st.lastAskAt = now; st.lastAskAt = now;
st.askVisible = visible; st.askVisible = visible;
// Paused (e.g. a user-started consolidation owns the cs_main budget): keep the stream waiting, admit
// nothing. It resumes immediately once unpaused; explicit needs still go through beginExplicit.
if (suppressed_[static_cast<std::size_t>(stream)]) return {};
pruneLeases(now); pruneLeases(now);
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
if (bestWaiter(now) != stream) return {}; if (bestWaiter(now) != stream) return {};
+16
View File
@@ -159,6 +159,21 @@ public:
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed 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 void reset(); // a new connection: forget everything; old tickets complete as no-ops
// Pause/resume a stream's ROUTINE scans (tryAdmit returns empty while paused; the stream keeps waiting
// and resumes the moment it is unpaused). Explicit needs (beginExplicit) are NEVER paused.
void suppressStream(ScanStream stream, bool paused) { suppressed_[static_cast<std::size_t>(stream)] = paused; }
bool streamSuppressed(ScanStream stream) const { return suppressed_[static_cast<std::size_t>(stream)]; }
// Convenience: pause/resume the heavy wallet-display scans (balance, addresses, history, recent-tx) as a
// group — used to hand a user-started consolidation more of the cs_main budget. Chat scans and Adhoc
// (the consolidation's own merge calls) are left running.
void suppressRoutineWalletScans(bool paused)
{
suppressStream(ScanStream::Balance, paused);
suppressStream(ScanStream::Addresses, paused);
suppressStream(ScanStream::History, paused);
suppressStream(ScanStream::RecentTx, paused);
}
bool inFlight(ScanStream stream) const; bool inFlight(ScanStream stream) const;
bool anyInFlight() const { return !live_.empty(); } bool anyInFlight() const { return !live_.empty(); }
double quietUntil() const { return quietUntil_; } double quietUntil() const { return quietUntil_; }
@@ -208,6 +223,7 @@ private:
Config cfg_; Config cfg_;
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{}; std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
std::array<bool, static_cast<std::size_t>(ScanStream::Count)> suppressed_{}; // routine scans paused per stream
std::vector<Live> live_; std::vector<Live> live_;
double quietUntil_ = 0.0; double quietUntil_ = 0.0;
int tipLag_ = 0; int tipLag_ = 0;
+17
View File
@@ -18030,6 +18030,23 @@ void testWalletScanGovernor()
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 372.0))); EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 372.0)));
EXPECT_FALSE(gov.waiting(ScanStream::Balance, 373.0)); // admitted: no longer waiting EXPECT_FALSE(gov.waiting(ScanStream::Balance, 373.0)); // admitted: no longer waiting
} }
{ // Suppression: a paused routine stream is never admitted (but keeps waiting); explicit needs bypass
// it; unpausing admits it again. Used to give a user consolidation the cs_main budget.
G gov;
gov.suppressRoutineWalletScans(true);
EXPECT_TRUE(gov.streamSuppressed(ScanStream::Balance));
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 0.0))); // paused: not admitted
EXPECT_TRUE(gov.waiting(ScanStream::Balance, 0.1)); // ...but still waiting
auto ex = gov.beginExplicit(ScanStream::Balance, sig, 0.2); // explicit bypasses it
EXPECT_TRUE(static_cast<bool>(ex));
gov.complete(ex, 0.0, ScanOutcome::Skipped, 0.3);
gov.suppressRoutineWalletScans(false);
EXPECT_FALSE(gov.streamSuppressed(ScanStream::Balance));
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 0.4))); // resumes once unpaused
// Chat scans and Adhoc (the merges themselves) are never in the suppressed group.
EXPECT_FALSE(gov.streamSuppressed(ScanStream::ChatNotes));
EXPECT_FALSE(gov.streamSuppressed(ScanStream::Adhoc));
}
} }
// The between-blocks probe's rotation: every z-address once (oldest-scanned first) before it is done. // The between-blocks probe's rotation: every z-address once (oldest-scanned first) before it is done.