perf(litebe): change-gate the idle lite refresh worker
The background 2 s refresh worker re-queried and re-copied the whole wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON build, copies and DOM parse), even when nothing had changed, so CPU and the network never went idle. The worker now skips the full refresh and the re-publish on a cycle that is provably unchanged, while never letting the UI show stale confirmed balance or transactions: - The lite bridge keeps a monotonic mutation epoch, bumped after any command not on a read-only allow-list, so a local change made on any thread (send, shield, import, new address, encrypt/unlock/lock, console) is detected at one choke point. - Each cycle the worker runs one cheap sync probe (one connection, the mandatory block ingest) and a local height read, and refreshes only when the scanned height advanced, the mutation epoch moved, the encryption state changed, a sync is in progress, or a ~10 s heartbeat elapsed; otherwise it skips. Direct/synchronous callers are not gated. - A failed probe backs off instead of running a second sync, so a dead server is not retried back-to-back. Confirmed funds can never be stale beyond one cycle (every confirmed change advances the height or bumps the epoch); only unconfirmed mempool rows can lag up to the heartbeat, and the spend path re-queries the backend directly rather than reading the gated model. Average idle cost drops from three data connections per cycle to about one. Tests: a bridge mutation-epoch test and a worker change-gate test (skip-when-unchanged; refresh on height advance, local mutation and encryption change) against the fake backend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -94,7 +94,8 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||
: api_(other.api_),
|
||||
unavailableReason_(std::move(other.unavailableReason_)),
|
||||
shutdownCalled_(other.shutdownCalled_)
|
||||
shutdownCalled_(other.shutdownCalled_),
|
||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||
{
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
@@ -107,6 +108,8 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||
api_ = other.api_;
|
||||
unavailableReason_ = std::move(other.unavailableReason_);
|
||||
shutdownCalled_ = other.shutdownCalled_;
|
||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||
std::memory_order_release);
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
return *this;
|
||||
@@ -158,11 +161,29 @@ LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, cons
|
||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||
}
|
||||
|
||||
bool LiteClientBridge::commandIsReadOnly(const std::string& c)
|
||||
{
|
||||
// Allow-list of commands that never alter wallet-visible data. sync/rescan are included: their
|
||||
// effect (new blocks) is observed through the worker's height signal, and the worker's own sync
|
||||
// probe must not self-dirty. UNKNOWN or omitted commands default to "mutating" (bump), so a
|
||||
// missing entry only ever costs one extra refresh -- never staleness.
|
||||
static const char* const kReadOnly[] = {
|
||||
"balance", "list", "notes", "addresses", "info", "height", "syncstatus",
|
||||
"encryptionstatus", "seed", "export", "save", "sync", "rescan", "help",
|
||||
"coinsupply", "saplingtree"};
|
||||
for (const char* r : kReadOnly)
|
||||
if (c == r) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, const std::string& args)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||
return takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||
return result;
|
||||
}
|
||||
|
||||
void LiteClientBridge::shutdown()
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dragonx {
|
||||
namespace wallet {
|
||||
@@ -69,15 +71,24 @@ public:
|
||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||
void shutdown();
|
||||
|
||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||
// local mutations made on ANY thread without hooking each controller method. Compare-only (never
|
||||
// cleared): the worker stores a baseline and refreshes whenever it differs, so a mutation can
|
||||
// never be "consumed"/lost in a race.
|
||||
std::uint64_t mutationEpoch() const { return mutationEpoch_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||
|
||||
LiteBridgeStringResult unavailableResult() const;
|
||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||
static bool commandIsReadOnly(const std::string& command);
|
||||
|
||||
LiteClientBridgeApi api_;
|
||||
std::string unavailableReason_;
|
||||
bool shutdownCalled_ = false;
|
||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||
};
|
||||
|
||||
} // namespace wallet
|
||||
|
||||
@@ -1587,25 +1587,105 @@ void LiteWalletController::stopWorker()
|
||||
|
||||
void LiteWalletController::workerLoop()
|
||||
{
|
||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
||||
while (running_.load()) {
|
||||
if (walletOpen_.load()) {
|
||||
bool syncReadFailed = false;
|
||||
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
|
||||
if (model && running_.load()) {
|
||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||
pendingModel_ = std::move(model);
|
||||
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||
if (decision == GateDecision::Skip) {
|
||||
// Nothing changed since the last full model: no gateway refresh, no re-publish.
|
||||
// pendingModel_ is left untouched (the UI keeps the last applied model).
|
||||
} else if (decision == GateDecision::ProbeFailed) {
|
||||
// The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT
|
||||
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||
failStreak += 1;
|
||||
} else { // DoFull
|
||||
// Sample the change baselines BEFORE the data commands (race rule).
|
||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||
const auto height0 = readScannedHeight();
|
||||
bool syncReadFailed = false;
|
||||
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
|
||||
if (model && running_.load()) {
|
||||
const auto& m = *model;
|
||||
const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete
|
||||
&& m.syncError.empty() && !syncReadFailed && height0.has_value();
|
||||
gate.haveFull = full;
|
||||
gate.height = height0.value_or(0);
|
||||
gate.epoch = epoch0;
|
||||
gate.encOk = m.hasEncryptionStatus;
|
||||
gate.encrypted = m.encrypted;
|
||||
gate.locked = m.locked;
|
||||
gate.fullAt = std::chrono::steady_clock::now();
|
||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||
pendingModel_ = std::move(model);
|
||||
} else {
|
||||
gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path
|
||||
}
|
||||
failStreak = syncReadFailed ? failStreak + 1 : 0;
|
||||
}
|
||||
failStreak = syncReadFailed ? failStreak + 1 : 0;
|
||||
}
|
||||
// Healthy: every 2 s. After a failed syncing read: 4 s, 8 s, then 15 s, so a dead server isn't retried
|
||||
// back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off).
|
||||
// Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead
|
||||
// server isn't retried back-to-back (each attempt is one do_sync with up to 62 s of back-off).
|
||||
std::unique_lock<std::mutex> lock(wakeMutex_);
|
||||
wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)),
|
||||
wakeCv_.wait_for(lock,
|
||||
std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())),
|
||||
[this] { return !running_.load(); });
|
||||
}
|
||||
}
|
||||
|
||||
// Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when
|
||||
// the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published
|
||||
// full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs
|
||||
// exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns
|
||||
// ProbeFailed so the caller backs off without a second sync.
|
||||
LiteWalletController::GateDecision
|
||||
LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak)
|
||||
{
|
||||
if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load())
|
||||
return GateDecision::DoFull;
|
||||
if (!syncDone_->load() || scan_->failed.load()) {
|
||||
g.haveFull = false;
|
||||
return GateDecision::DoFull;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() - g.fullAt
|
||||
>= std::chrono::milliseconds(gateHeartbeatMs_.load()))
|
||||
return GateDecision::DoFull; // heartbeat: bounded-staleness backstop
|
||||
if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline
|
||||
return GateDecision::DoFull;
|
||||
|
||||
// The mandatory one-connection confirmed-block ingest. Without it the wallet never advances.
|
||||
const auto probe = bridge_->execute("sync", "");
|
||||
if (!running_.load()) return GateDecision::DoFull;
|
||||
if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) {
|
||||
g.haveFull = false;
|
||||
return GateDecision::ProbeFailed;
|
||||
}
|
||||
|
||||
const auto h = readScannedHeight();
|
||||
if (!h.has_value() || *h == 0 || *h != g.height)
|
||||
return GateDecision::DoFull;
|
||||
|
||||
const auto enc = encryptionStatus();
|
||||
if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked)
|
||||
return GateDecision::DoFull;
|
||||
|
||||
if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull;
|
||||
if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; }
|
||||
|
||||
return GateDecision::Skip; // provably unchanged
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> LiteWalletController::readScannedHeight()
|
||||
{
|
||||
if (!bridge_) return std::nullopt;
|
||||
const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection)
|
||||
if (!h.ok) return std::nullopt;
|
||||
const auto parsed = parseLiteHeightResponse(h.value);
|
||||
if (!parsed.ok || parsed.height.height == 0) return std::nullopt;
|
||||
return static_cast<std::uint64_t>(parsed.height.height);
|
||||
}
|
||||
|
||||
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
|
||||
{
|
||||
auto result = lifecycle_.createWallet(request);
|
||||
|
||||
@@ -259,6 +259,8 @@ public:
|
||||
std::string scanError() const { return scan_->errorCopy(); }
|
||||
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
|
||||
void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); }
|
||||
void setRefreshIntervalMsForTest(int ms) { refreshIntervalMs_.store(ms); }
|
||||
void setGateHeartbeatMsForTest(int ms) { gateHeartbeatMs_.store(ms); }
|
||||
|
||||
// Launch the backend sync on a detached background thread (NON-blocking; the backend's
|
||||
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
|
||||
@@ -513,7 +515,23 @@ private:
|
||||
std::condition_variable wakeCv_;
|
||||
std::mutex modelMutex_;
|
||||
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by modelMutex_
|
||||
static constexpr int kRefreshIntervalMs = 2000;
|
||||
std::atomic<int> refreshIntervalMs_{2000};
|
||||
// C23: max time an UNCHANGED synced wallet may go without a full refresh+publish. Bounds the
|
||||
// staleness of same-height changes the cheap signals can't see (incoming mempool txs; pending
|
||||
// t-address scans) and backstops any external state reset. Atomic for test override.
|
||||
std::atomic<int> gateHeartbeatMs_{10000};
|
||||
|
||||
// Worker-thread-only snapshot of the last published FULL synced model (only workerLoop() touches it).
|
||||
struct RefreshGate {
|
||||
bool haveFull = false;
|
||||
std::uint64_t height = 0;
|
||||
std::uint64_t epoch = 0;
|
||||
bool encOk = false, encrypted = false, locked = false;
|
||||
std::chrono::steady_clock::time_point fullAt{};
|
||||
};
|
||||
enum class GateDecision { Skip, DoFull, ProbeFailed };
|
||||
GateDecision evaluateRefreshGate(RefreshGate& gate, int failStreak);
|
||||
std::optional<std::uint64_t> readScannedHeight();
|
||||
};
|
||||
|
||||
} // namespace wallet
|
||||
|
||||
Reference in New Issue
Block a user