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:
@@ -28,6 +28,7 @@
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "resources/embedded_resources.h"
|
||||
#include "services/history_scan_policy.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/refresh_scheduler.h"
|
||||
#include "services/note_consolidation.h"
|
||||
@@ -10476,6 +10477,526 @@ void testHistoryCollectorSweepAndEnrichment()
|
||||
}
|
||||
}
|
||||
|
||||
// C37: the scan governor's admission, floors, charging, change gate and lifecycle.
|
||||
void testWalletScanGovernor()
|
||||
{
|
||||
using namespace dragonx::services;
|
||||
using G = WalletScanGovernor;
|
||||
ScanSignals sig;
|
||||
sig.tipHash = "tip-a";
|
||||
sig.mempoolKnown = true;
|
||||
sig.mempoolTxs = 1;
|
||||
sig.mempoolBytes = 100;
|
||||
|
||||
{ // One scan at a time; after it, quiet until it was `budget` (15%) of the time since it started.
|
||||
G gov;
|
||||
auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0);
|
||||
EXPECT_TRUE(static_cast<bool>(t));
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 0.5))); // one at a time
|
||||
gov.complete(t, 1000.0, ScanOutcome::Ok, 1.0);
|
||||
EXPECT_NEAR(gov.quietUntil(), 1.0 + 1.0 * (1.0 / 0.15 - 1.0), 1e-6); // ~6.67
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.5))); // quiet
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 6.7))); // Addresses waited longer
|
||||
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 6.8)));
|
||||
}
|
||||
{ // FIFO among waiters; a visible one gets a 60 s head start; a stream that stopped asking isn't waiting.
|
||||
G gov;
|
||||
auto busy = gov.beginExplicit(ScanStream::Adhoc, sig, 0.0); // keeps the slot taken while they queue
|
||||
gov.tryAdmit(ScanStream::Addresses, false, sig, 1.0);
|
||||
gov.tryAdmit(ScanStream::History, false, sig, 2.0);
|
||||
gov.complete(busy, 0.0, ScanOutcome::Skipped, 3.0);
|
||||
gov.tryAdmit(ScanStream::History, false, sig, 3.0); // still waiting, but asked later
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::History, false, sig, 3.5)));
|
||||
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, false, sig, 3.5))); // first in
|
||||
G head;
|
||||
auto slot = head.beginExplicit(ScanStream::Adhoc, sig, 0.0);
|
||||
head.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // hidden, waiting since 1
|
||||
head.tryAdmit(ScanStream::Balance, true, sig, 30.0); // visible, waiting since 30 (ranks as -30)
|
||||
head.complete(slot, 0.0, ScanOutcome::Skipped, 31.0);
|
||||
head.tryAdmit(ScanStream::Addresses, false, sig, 31.0);
|
||||
EXPECT_TRUE(static_cast<bool>(head.tryAdmit(ScanStream::Balance, true, sig, 31.5)));
|
||||
G ttl;
|
||||
auto held = ttl.beginExplicit(ScanStream::Adhoc, sig, 0.0);
|
||||
ttl.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // then stops asking
|
||||
ttl.complete(held, 0.0, ScanOutcome::Skipped, 20.0);
|
||||
EXPECT_TRUE(static_cast<bool>(ttl.tryAdmit(ScanStream::History, false, sig, 20.0))); // Addresses lapsed
|
||||
}
|
||||
{ // During quiet, a waiter gets in only via its floor: max(30 s visible / 900 s hidden, 15x its cost).
|
||||
G gov;
|
||||
auto big = gov.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
gov.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet capped at now + 900
|
||||
EXPECT_NEAR(gov.quietUntil(), 1100.0, 1e-6);
|
||||
double t = 200.0;
|
||||
bool admitted = false;
|
||||
for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, t));
|
||||
EXPECT_TRUE(admitted);
|
||||
EXPECT_NEAR(t - 2.0, 230.0, 2.1); // waited 30 s (EMA 0)
|
||||
G costly;
|
||||
auto first = costly.tryAdmit(ScanStream::Addresses, true, sig, 0.0);
|
||||
costly.complete(first, 40000.0, ScanOutcome::Ok, 40.0); // EMA 40 s: floor 600 s
|
||||
auto again = costly.tryAdmit(ScanStream::History, true, sig, 300.0); // after that quiet
|
||||
EXPECT_TRUE(static_cast<bool>(again));
|
||||
costly.complete(again, 200000.0, ScanOutcome::Ok, 500.0); // quiet to 500 + 900
|
||||
admitted = false;
|
||||
for (t = 500.0; t < 1400.0 && !admitted; t += 2.0) admitted = static_cast<bool>(costly.tryAdmit(ScanStream::Addresses, true, sig, t));
|
||||
EXPECT_TRUE(admitted);
|
||||
EXPECT_NEAR(t - 2.0, 1100.0, 2.1); // 600 s
|
||||
G hidden;
|
||||
auto h = hidden.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
hidden.complete(h, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
|
||||
auto blocker = hidden.beginExplicit(ScanStream::Adhoc, sig, 1050.0);
|
||||
hidden.complete(blocker, 200000.0, ScanOutcome::Ok, 1060.0); // explicit: quiet only to +120 = 1180
|
||||
admitted = false;
|
||||
for (t = 200.0; t < 2000.0 && !admitted; t += 2.0) admitted = static_cast<bool>(hidden.tryAdmit(ScanStream::Balance, false, sig, t));
|
||||
EXPECT_TRUE(admitted);
|
||||
EXPECT_TRUE(t - 2.0 >= 1100.0 - 0.1); // hidden: not before its 900 s floor
|
||||
}
|
||||
{ // Charging: routine debt accumulates but quiet never runs past now + 900; explicit debt only to
|
||||
// now + 120 and never shortens quiet; TimedOut charges at least the usual cost; Skipped nothing.
|
||||
G gov;
|
||||
double now = 0.0;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
auto t = gov.beginExplicit(ScanStream::History, sig, now);
|
||||
gov.complete(t, 0.0, ScanOutcome::Skipped, now);
|
||||
auto r = gov.tryAdmit(ScanStream::Balance, true, sig, gov.quietUntil() + 1.0);
|
||||
now = gov.quietUntil() + 1.0;
|
||||
if (r) gov.complete(r, 200000.0, ScanOutcome::Ok, now);
|
||||
EXPECT_TRUE(gov.quietUntil() - now <= 900.0 + 1e-6);
|
||||
}
|
||||
G ex;
|
||||
auto e = ex.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
ex.complete(e, 100000.0, ScanOutcome::Ok, 10.0);
|
||||
EXPECT_NEAR(ex.quietUntil(), 130.0, 1e-6);
|
||||
auto r = ex.beginExplicit(ScanStream::Addresses, sig, 10.0);
|
||||
ex.complete(r, 1.0, ScanOutcome::Ok, 11.0);
|
||||
EXPECT_TRUE(ex.quietUntil() >= 130.0 && ex.quietUntil() <= 131.0); // adds its own bit, never shortens
|
||||
G timed;
|
||||
auto a = timed.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
timed.complete(a, 10000.0, ScanOutcome::Ok, 10.0);
|
||||
const double q1 = timed.quietUntil();
|
||||
auto b = timed.beginExplicit(ScanStream::Balance, sig, 200.0);
|
||||
timed.complete(b, 1000.0, ScanOutcome::TimedOut, 201.0); // charged the 10 s EMA, not 1 s
|
||||
EXPECT_TRUE(timed.quietUntil() >= 201.0 + 10.0 * (1.0 / 0.15 - 1.0) - 1e-6 || timed.quietUntil() >= 201.0 + 120.0 - 1e-6);
|
||||
EXPECT_TRUE(q1 > 0.0);
|
||||
auto c = timed.beginExplicit(ScanStream::Addresses, sig, 1000.0);
|
||||
const double before = timed.quietUntil();
|
||||
timed.complete(c, 50000.0, ScanOutcome::Skipped, 1001.0);
|
||||
EXPECT_NEAR(timed.quietUntil(), before, 1e-9); // Skipped: nothing sent, nothing charged
|
||||
G ema;
|
||||
auto e1 = ema.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
ema.complete(e1, 10000.0, ScanOutcome::Ok, 1.0);
|
||||
auto e2 = ema.beginExplicit(ScanStream::Balance, sig, 2.0);
|
||||
ema.complete(e2, 30000.0, ScanOutcome::Ok, 3.0);
|
||||
EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 30000.0, 1e-6); // rises at once
|
||||
auto e3 = ema.beginExplicit(ScanStream::Balance, sig, 4.0);
|
||||
ema.complete(e3, 10000.0, ScanOutcome::Ok, 5.0);
|
||||
EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 24000.0, 1e-6); // decays slowly (0.7 / 0.3)
|
||||
G lag;
|
||||
EXPECT_NEAR(lag.currentBudget(), 0.15, 1e-12);
|
||||
lag.setTipLag(1);
|
||||
EXPECT_NEAR(lag.currentBudget(), 0.10, 1e-12);
|
||||
lag.setTipLag(5);
|
||||
EXPECT_NEAR(lag.currentBudget(), 0.05, 1e-12);
|
||||
}
|
||||
{ // The change gate.
|
||||
G gov;
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 0.0)); // no basis yet
|
||||
auto t = gov.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
gov.complete(t, 100.0, ScanOutcome::Ok, 1.0);
|
||||
EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 10.0));
|
||||
ScanSignals pool = sig;
|
||||
pool.mempoolTxs = 2;
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, pool, 10.0));
|
||||
ScanSignals epoch = sig;
|
||||
epoch.walletEpoch = 1;
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, epoch, 10.0));
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 300.0)); // the safety sweep
|
||||
ScanSignals unknown = sig;
|
||||
unknown.mempoolKnown = false;
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, unknown, 10.0)); // unknown never matches
|
||||
ScanSignals noTip = sig;
|
||||
noTip.tipHash.clear();
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, noTip, 10.0));
|
||||
// History compares the tip and the epoch only.
|
||||
auto h = gov.beginExplicit(ScanStream::History, sig, 20.0);
|
||||
gov.complete(h, 100.0, ScanOutcome::Ok, 21.0);
|
||||
EXPECT_FALSE(gov.needed(ScanStream::History, pool, 30.0));
|
||||
ScanSignals tip = sig;
|
||||
tip.tipHash = "tip-b";
|
||||
EXPECT_TRUE(gov.needed(ScanStream::History, tip, 30.0));
|
||||
// A failure keeps the basis.
|
||||
auto f = gov.beginExplicit(ScanStream::Balance, tip, 40.0);
|
||||
gov.complete(f, 100.0, ScanOutcome::Failed, 41.0);
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, tip, 50.0));
|
||||
EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 50.0));
|
||||
// A change that lands while the scan runs is still needed afterwards (the basis is the dispatch's).
|
||||
auto d = gov.beginExplicit(ScanStream::Balance, pool, 60.0);
|
||||
ScanSignals later = pool;
|
||||
later.mempoolTxs = 3;
|
||||
gov.complete(d, 100.0, ScanOutcome::Ok, 61.0);
|
||||
EXPECT_FALSE(gov.needed(ScanStream::Balance, pool, 62.0));
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, later, 62.0));
|
||||
// A pass: Partial then Ok adopts the first dispatch's basis; a failure inside poisons it.
|
||||
G pass;
|
||||
ScanSignals p1 = sig, p2 = sig;
|
||||
p1.tipHash = "p1";
|
||||
p2.tipHash = "p2";
|
||||
auto pa = pass.beginExplicit(ScanStream::Addresses, p1, 0.0);
|
||||
pass.complete(pa, 10.0, ScanOutcome::Partial, 1.0);
|
||||
auto pb = pass.beginExplicit(ScanStream::Addresses, p2, 2.0);
|
||||
pass.complete(pb, 10.0, ScanOutcome::Ok, 3.0);
|
||||
EXPECT_FALSE(pass.needed(ScanStream::Addresses, p1, 4.0));
|
||||
EXPECT_TRUE(pass.needed(ScanStream::Addresses, p2, 4.0));
|
||||
G poisoned;
|
||||
auto qa = poisoned.beginExplicit(ScanStream::Addresses, p1, 0.0);
|
||||
poisoned.complete(qa, 10.0, ScanOutcome::Partial, 1.0);
|
||||
auto qb = poisoned.beginExplicit(ScanStream::Addresses, p1, 2.0);
|
||||
poisoned.complete(qb, 10.0, ScanOutcome::Failed, 3.0);
|
||||
auto qc = poisoned.beginExplicit(ScanStream::Addresses, p1, 4.0);
|
||||
poisoned.complete(qc, 10.0, ScanOutcome::Ok, 5.0);
|
||||
EXPECT_TRUE(poisoned.needed(ScanStream::Addresses, p1, 6.0));
|
||||
gov.invalidate(ScanStream::Balance);
|
||||
EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 70.0));
|
||||
}
|
||||
{ // Lifecycle.
|
||||
G gov;
|
||||
auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0);
|
||||
gov.cancel(t); // not sent: no charge, wait restored
|
||||
EXPECT_FALSE(gov.anyInFlight());
|
||||
EXPECT_NEAR(gov.quietUntil(), 0.0, 1e-12);
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 1.0))); // Balance is ahead
|
||||
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 2.0)));
|
||||
G reset;
|
||||
auto old = reset.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
reset.reset();
|
||||
reset.complete(old, 1e6, ScanOutcome::Ok, 1.0); // from the old connection: no-op
|
||||
EXPECT_NEAR(reset.quietUntil(), 0.0, 1e-12);
|
||||
EXPECT_TRUE(reset.needed(ScanStream::Balance, sig, 2.0));
|
||||
G orphan;
|
||||
auto o1 = orphan.beginExplicit(ScanStream::Addresses, sig, 0.0);
|
||||
orphan.complete(o1, 10000.0, ScanOutcome::Ok, 10.0);
|
||||
const double q = orphan.quietUntil();
|
||||
orphan.beginExplicit(ScanStream::Addresses, sig, 200.0);
|
||||
EXPECT_TRUE(orphan.inFlight(ScanStream::Addresses));
|
||||
orphan.dropOrphans(ScanStream::Addresses, 300.0); // its job is gone: freed, charged
|
||||
EXPECT_FALSE(orphan.inFlight(ScanStream::Addresses));
|
||||
EXPECT_TRUE(orphan.quietUntil() > q);
|
||||
G lease;
|
||||
auto stuck = lease.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
EXPECT_TRUE(static_cast<bool>(stuck));
|
||||
EXPECT_FALSE(static_cast<bool>(lease.tryAdmit(ScanStream::Balance, true, sig, 599.0)));
|
||||
EXPECT_TRUE(static_cast<bool>(lease.tryAdmit(ScanStream::Balance, true, sig, 601.0))); // presumed lost
|
||||
lease.complete(stuck, 1e6, ScanOutcome::Ok, 602.0); // a late result: ignored
|
||||
EXPECT_NEAR(lease.quietUntil(), 0.0, 1e-12);
|
||||
G adhoc;
|
||||
auto a1 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0);
|
||||
auto a2 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0);
|
||||
adhoc.complete(a1, 0.0, ScanOutcome::Skipped, 1.0);
|
||||
EXPECT_TRUE(adhoc.inFlight(ScanStream::Adhoc));
|
||||
adhoc.complete(a2, 0.0, ScanOutcome::Skipped, 1.0);
|
||||
EXPECT_FALSE(adhoc.inFlight(ScanStream::Adhoc));
|
||||
}
|
||||
{ // A stream that stopped asking for over 60 s starts a new wait: no floor bypass from the old one.
|
||||
G lapse;
|
||||
auto big = lapse.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
lapse.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
|
||||
EXPECT_FALSE(static_cast<bool>(lapse.tryAdmit(ScanStream::Balance, true, sig, 200.0)));
|
||||
double t = 261.0; // silent for 61 s
|
||||
bool admitted = false;
|
||||
for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast<bool>(lapse.tryAdmit(ScanStream::Balance, true, sig, t));
|
||||
EXPECT_TRUE(admitted);
|
||||
EXPECT_NEAR(t - 2.0, 291.0, 2.1); // 30 s from 261 (without the rule: 261)
|
||||
}
|
||||
{ // An explicit charge never shortens a routine quiet.
|
||||
G keep;
|
||||
auto r = keep.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
keep.complete(r, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100
|
||||
auto e = keep.beginExplicit(ScanStream::Balance, sig, 300.0);
|
||||
keep.complete(e, 50000.0, ScanOutcome::Ok, 310.0); // its own cap is 430
|
||||
EXPECT_NEAR(keep.quietUntil(), 1100.0, 1e-6);
|
||||
}
|
||||
{ // An explicit ticket that doesn't do the routine work (a Light history cycle): charged, but the stream
|
||||
// keeps waiting for that work, and its cost estimate stays the routine scans'.
|
||||
G gov;
|
||||
auto h = gov.tryAdmit(ScanStream::History, true, sig, 0.0);
|
||||
gov.complete(h, 120000.0, ScanOutcome::Ok, 0.0); // EMA 120 s, quiet to 680
|
||||
auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 700.0);
|
||||
gov.tryAdmit(ScanStream::History, true, sig, 701.0);
|
||||
gov.tryAdmit(ScanStream::Addresses, true, sig, 702.0);
|
||||
auto light = gov.beginExplicit(ScanStream::History, sig, 703.0, false);
|
||||
gov.complete(light, 1000.0, ScanOutcome::Ok, 704.0); // quiet to ~709.7
|
||||
EXPECT_NEAR(gov.emaCostMs(ScanStream::History), 120000.0, 1e-6);
|
||||
EXPECT_TRUE(gov.quietUntil() > 709.0);
|
||||
gov.complete(slot, 0.0, ScanOutcome::Skipped, 705.0);
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Addresses, true, sig, 712.0))); // History is older
|
||||
EXPECT_TRUE(static_cast<bool>(gov.tryAdmit(ScanStream::History, true, sig, 712.5)));
|
||||
}
|
||||
{ // Only scans that were sent count: a cancelled or Skipped ticket doesn't restart the hidden interval,
|
||||
// and a Skipped routine ticket keeps its place in line.
|
||||
G gov;
|
||||
auto c = gov.tryAdmit(ScanStream::Balance, false, sig, 0.0);
|
||||
gov.cancel(c);
|
||||
EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 10.0) > 1e8);
|
||||
auto s1 = gov.tryAdmit(ScanStream::Balance, false, sig, 10.0);
|
||||
EXPECT_TRUE(static_cast<bool>(s1));
|
||||
gov.complete(s1, 0.0, ScanOutcome::Skipped, 11.0);
|
||||
EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 20.0) > 1e8);
|
||||
auto o = gov.tryAdmit(ScanStream::Balance, false, sig, 20.0);
|
||||
EXPECT_TRUE(static_cast<bool>(o));
|
||||
gov.complete(o, 1000.0, ScanOutcome::Ok, 21.0);
|
||||
EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 30.0), 10.0, 1e-9);
|
||||
auto f = gov.beginExplicit(ScanStream::Balance, sig, 40.0);
|
||||
gov.complete(f, 1000.0, ScanOutcome::Failed, 41.0); // sent, so it counts
|
||||
EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 50.0), 10.0, 1e-9);
|
||||
G fifo;
|
||||
auto busy = fifo.beginExplicit(ScanStream::Adhoc, sig, 0.0);
|
||||
fifo.tryAdmit(ScanStream::Balance, true, sig, 1.0);
|
||||
fifo.tryAdmit(ScanStream::Addresses, true, sig, 5.0);
|
||||
fifo.complete(busy, 0.0, ScanOutcome::Skipped, 9.0);
|
||||
auto b = fifo.tryAdmit(ScanStream::Balance, true, sig, 10.0);
|
||||
EXPECT_TRUE(static_cast<bool>(b));
|
||||
fifo.complete(b, 0.0, ScanOutcome::Skipped, 11.0); // the node was behind: nothing sent
|
||||
EXPECT_FALSE(static_cast<bool>(fifo.tryAdmit(ScanStream::Addresses, true, sig, 12.0))); // Balance kept its place
|
||||
EXPECT_TRUE(static_cast<bool>(fifo.tryAdmit(ScanStream::Balance, true, sig, 12.5)));
|
||||
}
|
||||
{ // waiting / heldWithinFloor / visibleFloorSecFor.
|
||||
G gov;
|
||||
EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 30.0, 1e-9);
|
||||
auto first = gov.beginExplicit(ScanStream::Balance, sig, 0.0);
|
||||
gov.complete(first, 21000.0, ScanOutcome::Ok, 21.0); // EMA 21 s: floor 315 s
|
||||
EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 315.0, 1e-9);
|
||||
auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 30.0);
|
||||
EXPECT_FALSE(gov.waiting(ScanStream::Balance, 30.0));
|
||||
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 30.0));
|
||||
EXPECT_FALSE(static_cast<bool>(gov.tryAdmit(ScanStream::Balance, true, sig, 31.0))); // denied: waits
|
||||
EXPECT_TRUE(gov.waiting(ScanStream::Balance, 32.0));
|
||||
EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 32.0));
|
||||
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 92.0)); // stopped asking (> 60 s)
|
||||
double t = 31.0;
|
||||
for (; t < 360.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t); // the slot stays taken
|
||||
EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 360.0)); // 329 s < 315 + 21
|
||||
for (; t < 370.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t);
|
||||
EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 370.0)); // past floor + one scan: something's wrong
|
||||
gov.complete(slot, 0.0, ScanOutcome::Skipped, 371.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
|
||||
}
|
||||
}
|
||||
|
||||
// The between-blocks probe's rotation: every z-address once (oldest-scanned first) before it is done.
|
||||
void testProbeRotation()
|
||||
{
|
||||
using dragonx::services::ProbeRotation;
|
||||
const std::vector<std::string> addresses{"za", "zb", "zc"};
|
||||
std::unordered_map<std::string, int> heights{{"za", 100}, {"zb", 90}, {"zc", 100}};
|
||||
ProbeRotation rotation;
|
||||
EXPECT_TRUE(rotation.complete());
|
||||
EXPECT_EQ(rotation.next(addresses, heights), std::string("zb")); // oldest first
|
||||
rotation.probed("zb");
|
||||
heights["zb"] = 101;
|
||||
EXPECT_FALSE(rotation.complete());
|
||||
EXPECT_EQ(rotation.next(addresses, heights), std::string("za")); // tie: list order
|
||||
rotation.probed("za");
|
||||
heights["za"] = 101;
|
||||
EXPECT_EQ(rotation.next(addresses, heights), std::string("zc"));
|
||||
rotation.probed("zc");
|
||||
EXPECT_TRUE(rotation.complete());
|
||||
// A new rotation after a change: all heights equal now, still every address once.
|
||||
heights["zc"] = 101;
|
||||
EXPECT_EQ(rotation.next(addresses, heights), std::string("za"));
|
||||
rotation.probed("za");
|
||||
EXPECT_EQ(rotation.next(addresses, heights), std::string("zb"));
|
||||
// An address that left the list is dropped; one never scanned goes first.
|
||||
const std::vector<std::string> changed{"zb", "zd"};
|
||||
EXPECT_EQ(rotation.next(changed, heights), std::string("zb")); // za/zc gone, zd new but not in this rotation
|
||||
rotation.probed("zb");
|
||||
EXPECT_TRUE(rotation.complete());
|
||||
EXPECT_EQ(rotation.next(changed, heights), std::string("zd")); // new rotation: never scanned first
|
||||
rotation.reset();
|
||||
EXPECT_TRUE(rotation.complete());
|
||||
EXPECT_EQ(rotation.next({}, heights), std::string());
|
||||
}
|
||||
|
||||
// C37 under overload: three visible streams that always want to scan (21 s, 25 s and 120 s per scan, a
|
||||
// large wallet) for 2 h. Together they stay near the budget (the old per-stream 10% rules added up to
|
||||
// 30-40%), none starves, and quiet never runs away; hidden, each still gets in within its floor.
|
||||
void testWalletScanGovernorOverload()
|
||||
{
|
||||
using namespace dragonx::services;
|
||||
WalletScanGovernor gov;
|
||||
const ScanStream streams[3] = {ScanStream::Balance, ScanStream::Addresses, ScanStream::History};
|
||||
const double cost[3] = {21.0, 25.0, 120.0};
|
||||
double lastAdmit[3] = {0.0, 0.0, 0.0};
|
||||
double maxGapVisible[3] = {0.0, 0.0, 0.0};
|
||||
double maxGapHidden[3] = {0.0, 0.0, 0.0};
|
||||
ScanTicket live;
|
||||
int liveIndex = -1;
|
||||
double liveEnd = 0.0, scanSec = 0.0, maxQuietAhead = 0.0;
|
||||
const ScanSignals sig;
|
||||
const double visibleEnd = 7200.0, end = 10800.0;
|
||||
for (double now = 0.0; now < end; now += 2.0) {
|
||||
const bool visible = now < visibleEnd;
|
||||
if (live && now >= liveEnd) {
|
||||
gov.complete(live, cost[liveIndex] * 1000.0, ScanOutcome::Ok, now);
|
||||
live = ScanTicket{};
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
auto t = gov.tryAdmit(streams[i], visible, sig, now);
|
||||
if (!t) continue;
|
||||
live = t;
|
||||
liveIndex = i;
|
||||
liveEnd = now + cost[i];
|
||||
if (now < visibleEnd) scanSec += cost[i];
|
||||
const double gap = now - lastAdmit[i];
|
||||
if (now < visibleEnd) maxGapVisible[i] = std::max(maxGapVisible[i], gap);
|
||||
else if (lastAdmit[i] >= visibleEnd) maxGapHidden[i] = std::max(maxGapHidden[i], gap);
|
||||
lastAdmit[i] = now;
|
||||
}
|
||||
maxQuietAhead = std::max(maxQuietAhead, gov.quietUntil() - now);
|
||||
}
|
||||
EXPECT_TRUE(scanSec / visibleEnd <= 0.22);
|
||||
EXPECT_TRUE(scanSec / visibleEnd >= 0.10);
|
||||
EXPECT_TRUE(maxQuietAhead <= 900.0 + 1e-6);
|
||||
// A stream's longest wait: its floor, plus the other two scans if both are queued ahead of it.
|
||||
const double others[3] = {25.0 + 120.0, 21.0 + 120.0, 21.0 + 25.0};
|
||||
const double visibleFloor[3] = {315.0, 375.0, 1800.0}; // max(300 s, 15x cost)
|
||||
for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapVisible[i] <= visibleFloor[i] + others[i] + 4.0);
|
||||
const double hiddenFloor[3] = {900.0, 900.0, 1800.0}; // max(900 s, 15x cost)
|
||||
for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapHidden[i] <= hiddenFloor[i] + others[i] + 4.0);
|
||||
}
|
||||
|
||||
void testBalancePollDecisionAndVisibility()
|
||||
{
|
||||
using namespace dragonx::services;
|
||||
using D = BalancePollDecision;
|
||||
EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, false), 600.0, 1e-9);
|
||||
EXPECT_NEAR(hiddenBalanceIntervalSec(20.0, false), 1000.0, 1e-9);
|
||||
EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, true), 120.0, 1e-9);
|
||||
BalancePollInputs in;
|
||||
EXPECT_TRUE(decideBalancePoll(in) == D::AskGovernor);
|
||||
{ auto x = in; x.allowBalance = false; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.syncing = true; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.forced = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.neverFetched = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); }
|
||||
{ auto x = in; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.visible = false; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = in; x.visible = false; x.sinceLastScanSec = 700.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
|
||||
{ auto x = in; x.visible = false; x.autoShieldArmed = true; x.sinceLastScanSec = 130.0;
|
||||
EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
|
||||
// Coming into view with an old value: one explicit poll. Each guard alone flips it back.
|
||||
BalancePollInputs back = in;
|
||||
back.shownAgeSec = 400.0;
|
||||
back.visibleFloorSec = 315.0;
|
||||
back.sinceLastScanSec = 400.0;
|
||||
EXPECT_TRUE(decideBalancePoll(back) == D::Explicit);
|
||||
{ auto x = back; x.waiting = true; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // already queued
|
||||
{ auto x = back; x.shownAgeSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); }
|
||||
{ auto x = back; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // just tried
|
||||
{ auto x = back; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); }
|
||||
{ auto x = back; x.visible = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } // hidden: its own interval
|
||||
|
||||
using NP = dragonx::ui::NavPage;
|
||||
auto overview = scanVisibilityForPage(NP::Overview, false);
|
||||
EXPECT_TRUE(overview.balance && overview.addresses && overview.history && !overview.chat);
|
||||
for (auto page : {NP::Send, NP::Receive}) {
|
||||
auto v = scanVisibilityForPage(page, false);
|
||||
EXPECT_TRUE(!v.balance && v.addresses && v.history);
|
||||
}
|
||||
auto history = scanVisibilityForPage(NP::History, false);
|
||||
EXPECT_TRUE(!history.balance && !history.addresses && history.history);
|
||||
auto market = scanVisibilityForPage(NP::Market, false);
|
||||
EXPECT_TRUE(market.balance && !market.addresses && !market.history);
|
||||
auto chat = scanVisibilityForPage(NP::Chat, false);
|
||||
EXPECT_TRUE(chat.chat && !chat.balance);
|
||||
auto mining = scanVisibilityForPage(NP::Mining, false);
|
||||
EXPECT_TRUE(!mining.balance && !mining.addresses && !mining.history && !mining.chat);
|
||||
auto background = scanVisibilityForPage(NP::Overview, true);
|
||||
EXPECT_TRUE(!background.balance && !background.addresses && !background.history && !background.chat);
|
||||
}
|
||||
|
||||
// C36: the Core poll reads the mempool's size (no cs_main) right after the chain, for the change gate.
|
||||
void testCoreRefreshMempool()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
using json = nlohmann::json;
|
||||
using dragonx::rpc::RpcFailureKind;
|
||||
using dragonx::rpc::RpcTransportError;
|
||||
const json chain = json{{"blocks", 150}, {"headers", 150}, {"longestchain", 150}, {"bestblockhash", "tip-150"}};
|
||||
const json total = json{{"private", "3.00000000"}, {"transparent", "1.25000000"}, {"total", "4.25000000"}};
|
||||
Refresh::CoreRefreshPlan plan;
|
||||
plan.fetchMempool = true;
|
||||
plan.needSpendable = false;
|
||||
plan.prevTotalZat = 425000000;
|
||||
{
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("getblockchaininfo", chain);
|
||||
rpc.addResponse("getmempoolinfo", json{{"size", 3}, {"bytes", 4500}, {"usage", 9000}});
|
||||
rpc.addResponse("z_gettotalbalance", total);
|
||||
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"}));
|
||||
EXPECT_EQ(rpc.calls[1].params, json::array());
|
||||
EXPECT_TRUE(result.mempoolFetched && result.mempoolOk);
|
||||
EXPECT_EQ(result.mempoolTxs, static_cast<std::int64_t>(3));
|
||||
EXPECT_EQ(result.mempoolBytes, static_cast<std::int64_t>(4500));
|
||||
}
|
||||
{ // A failed read leaves the mempool unknown (counts as changed); the balance still runs.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("getblockchaininfo", chain);
|
||||
rpc.addFailure("getmempoolinfo", "boom");
|
||||
rpc.addResponse("z_gettotalbalance", total);
|
||||
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
|
||||
EXPECT_TRUE(result.mempoolFetched && !result.mempoolOk);
|
||||
EXPECT_TRUE(result.balanceOk);
|
||||
}
|
||||
{ // Behind: chain + mempool only.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}});
|
||||
rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}});
|
||||
Refresh::collectCoreRefreshResult(rpc, plan);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo", "getmempoolinfo"}));
|
||||
}
|
||||
{ // The chain read found the node busy: nothing else.
|
||||
MockRefreshRpc rpc;
|
||||
rpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError(
|
||||
RpcFailureKind::QueueFull, "RPC error: HTTP 500: Work queue depth exceeded", CURLE_OK, 500, true)));
|
||||
auto result = Refresh::collectCoreRefreshResult(rpc, plan);
|
||||
EXPECT_TRUE(rpc.methodNames() == std::vector<std::string>({"getblockchaininfo"}));
|
||||
EXPECT_FALSE(result.mempoolFetched);
|
||||
}
|
||||
}
|
||||
|
||||
void testRefreshSchedulerRetrySoon()
|
||||
{
|
||||
using dragonx::services::RefreshScheduler;
|
||||
using Timer = RefreshScheduler::Timer;
|
||||
RefreshScheduler early;
|
||||
early.setIntervals({5.0f, 10.0f, 15.0f, 20.0f});
|
||||
early.reset(Timer::Transactions);
|
||||
early.retrySoon(Timer::Transactions, 2.0f);
|
||||
EXPECT_FALSE(early.isDue(Timer::Transactions));
|
||||
early.tick(1.9f);
|
||||
EXPECT_FALSE(early.isDue(Timer::Transactions));
|
||||
RefreshScheduler due;
|
||||
due.setIntervals({5.0f, 10.0f, 15.0f, 20.0f});
|
||||
due.reset(Timer::Transactions);
|
||||
due.retrySoon(Timer::Transactions, 2.0f);
|
||||
due.tick(2.0f);
|
||||
EXPECT_TRUE(due.isDue(Timer::Transactions));
|
||||
RefreshScheduler syncing; // the sync profile disables the timer: a retry never fires mid-sync
|
||||
syncing.setIntervals(RefreshScheduler::kSyncProfile);
|
||||
syncing.retrySoon(Timer::Transactions, 2.0f);
|
||||
syncing.tick(60.0f);
|
||||
EXPECT_FALSE(syncing.isDue(Timer::Transactions));
|
||||
|
||||
// getmempoolinfo takes no cs_main: always sent, and its replies say nothing about busyness.
|
||||
dragonx::rpc::RpcBusyGate gate;
|
||||
gate.record("z_gettotalbalance", dragonx::rpc::RpcFailureKind::ReplyTimeout);
|
||||
EXPECT_TRUE(gate.busy());
|
||||
EXPECT_TRUE(gate.admit("getmempoolinfo"));
|
||||
gate.record("getmempoolinfo", dragonx::rpc::RpcFailureKind::None);
|
||||
EXPECT_TRUE(gate.busy());
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
@@ -10504,6 +11025,12 @@ int main()
|
||||
testHistoryPassWithCollector();
|
||||
testHistoryCollectorSweepAndEnrichment();
|
||||
testHistoryDutyModel();
|
||||
testWalletScanGovernor();
|
||||
testWalletScanGovernorOverload();
|
||||
testProbeRotation();
|
||||
testBalancePollDecisionAndVisibility();
|
||||
testCoreRefreshMempool();
|
||||
testRefreshSchedulerRetrySoon();
|
||||
testNetworkRefreshResultModels();
|
||||
testTrackedSendRetirement();
|
||||
testBalanceDisplayLerp();
|
||||
|
||||
Reference in New Issue
Block a user