feat(merge): run per-address consolidation as a paced background job
"Keep in each address" posted one task that called z_mergetoaddress for every batch of every address back to back; each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a time anyway. It is now an App-owned services::ConsolidationRun: - one RPC at a time; the next merge only after the previous batch left the queue and a gap as long as the last call took (2-120 s); - pauses while the node syncs, the wallet is locked, a send of ours is in progress, or another wallet scan is running; - failures classified: nothing that may have queued is ever resent; a breaker stops after 3 dead addresses or 3 failed ops in a row; - each merge is charged to the scan budget; one re-read and one forced balance at the end; - it keeps running when the dialog closes: a status-bar chip, a run view with progress, the wait reason and a Stop button. The "Into one" merge can no longer be confirmed twice while its call is in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and FAKE_MERGE_FAIL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -67,6 +68,168 @@ struct AddressRun {
|
||||
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
|
||||
|
||||
// ── Merge-call stepper ──
|
||||
// One call's result into the address's run (r.error set: run.error = r.error, false). True while another call
|
||||
// for this address is worth making: at least 2 unlocked notes left and fewer than maxBatches submitted.
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches = 20);
|
||||
// z_mergetoaddress's reply. Never throws. A non-empty string "opid" = accepted; mergingNotes/remainingNotes only
|
||||
// when integral (clamped to [0, INT_MAX]), else 0; no opid: error "no operation id".
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out);
|
||||
|
||||
// ── Async op status ──
|
||||
enum class OpState : std::uint8_t { Unknown, Queued, Executing, Success, Failed, Lost };
|
||||
inline bool opStateTerminal(OpState s) { return s == OpState::Success || s == OpState::Failed || s == OpState::Lost; }
|
||||
struct OpReport {
|
||||
std::string id, status, error, txid; // error = error.message, txid = result.txid
|
||||
};
|
||||
OpState opStateFromStatus(const std::string& status); // queued/executing/success; failed|cancelled|canceled: Failed
|
||||
// Type-checked, never throws. Skips non-objects and missing/non-string/empty ids; keeps only `wanted` (all if empty).
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted = {});
|
||||
|
||||
// ── Merge-call failures ──
|
||||
enum class MergeFailure : std::uint8_t {
|
||||
NotSent, // turned away before z_mergetoaddress ran: the same batch again, later
|
||||
Syncing, // THROW_IF_SYNCING: hold the run while the node catches up
|
||||
NeedsUnlock, // a locked keystore ("could not find spending key")
|
||||
NothingLeft, // no funds / already merged / too small for the fee
|
||||
Uncertain, // it may have queued the merge (a reply timeout, an answer without an opid): never resent
|
||||
Transient, // the HTTP layer dropped it, or the async queue is shutting down: retry with backoff
|
||||
Fatal, // anything else: this address is done
|
||||
};
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
||||
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
||||
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
||||
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e);
|
||||
|
||||
// ── Paced per-address consolidation run (owned by App, pumped from App::update(); main thread) ──
|
||||
// The "Keep in each address" mode used to post one task that called z_mergetoaddress for every batch of every
|
||||
// address back to back. Each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a
|
||||
// time anyway. Here: one run RPC at a time, the next call only after the previous batch left the queue and a
|
||||
// gap as long as the last call took, failures classified (nothing that may have queued is ever resent), and a
|
||||
// breaker after three dead addresses or three failed ops in a row.
|
||||
inline constexpr double kRunMinGapSec = 2.0;
|
||||
inline constexpr double kRunMaxGapSec = 120.0;
|
||||
inline constexpr double kRunPollIntervalSec = 3.0;
|
||||
inline constexpr long kRunCallTimeoutSec = 120;
|
||||
inline constexpr double kRunWatchdogSec = 1800.0; // a call or poll outstanding this long is presumed lost
|
||||
inline constexpr double kRunOpMaxSec = 3600.0; // an op not terminal after this much pollable time is presumed lost
|
||||
inline constexpr double kRunOpHardMaxSec = 4 * kRunOpMaxSec; // ...and after this long whatever happened
|
||||
inline constexpr double kRunNotSentRetrySec = 5.0;
|
||||
inline constexpr double kRunSyncHoldSec = 30.0;
|
||||
inline constexpr double kRunUnlockHoldSec = 30.0;
|
||||
inline constexpr int kRunMaxTransientRetries = 5;
|
||||
inline constexpr int kRunMaxUnlockRefusals = 2;
|
||||
inline constexpr int kRunMaxFailStreak = 3;
|
||||
inline constexpr int kRunMaxPollMisses = 2;
|
||||
|
||||
struct RunConfig {
|
||||
double fee = 0.0001;
|
||||
int limit = 50;
|
||||
int maxBatches = 20;
|
||||
};
|
||||
struct RunEnv {
|
||||
bool nodeUsable = false; // connected, not warming up or rescanning
|
||||
bool busy = false; // the busy gate holds RPCs that need cs_main
|
||||
bool callsAllowed = true; // not syncing / settling
|
||||
bool locked = false;
|
||||
bool userSendBusy = false; // a send / shield / auto-shield / chat op of ours is in progress
|
||||
bool scanBusy = false; // another GUI wallet scan is in the daemon
|
||||
};
|
||||
enum class RunAction : std::uint8_t { None, Call, Poll, Finish };
|
||||
enum class RunWait : std::uint8_t { None, InFlight, Node, Busy, Syncing, Locked, UserSend, Scan, Batch };
|
||||
enum class RunEnd : std::uint8_t { None, Completed, Stopped, Halted, Aborted };
|
||||
enum class AddrOutcome : std::uint8_t { Pending, Running, Done, Uncertain, Failed }; // ordered: worse wins
|
||||
enum class RunVerdict : std::uint8_t { None, Clean, NothingSubmitted, SomeFailed, Unconfirmed, Stopped, Halted, Aborted };
|
||||
struct RunStep {
|
||||
RunAction action = RunAction::None;
|
||||
int addrIdx = -1;
|
||||
};
|
||||
struct RunAddress {
|
||||
NoteGroup group;
|
||||
AddressRun run;
|
||||
AddrOutcome outcome = AddrOutcome::Pending;
|
||||
bool done = false;
|
||||
int opsDone = 0;
|
||||
int transient = 0;
|
||||
int unlockRefusals = 0;
|
||||
std::string failure;
|
||||
};
|
||||
struct RunBatch {
|
||||
int addrIdx = 0;
|
||||
std::string opid;
|
||||
int merging = 0;
|
||||
OpState state = OpState::Unknown;
|
||||
std::string error, txid;
|
||||
int misses = 0;
|
||||
double acceptedAt = 0.0;
|
||||
double pollableSec = 0.0; // time since accepted in which a status poll could have gone out
|
||||
};
|
||||
struct RunProgress {
|
||||
int addresses = 0, addressesDone = 0, batchesSubmitted = 0, batchesEstimated = 0, batchesDone = 0, notesMerged = 0;
|
||||
bool stopping = false;
|
||||
RunWait wait = RunWait::None;
|
||||
};
|
||||
struct RunSummary {
|
||||
RunEnd end = RunEnd::None;
|
||||
int addresses = 0, addressesFailed = 0, addressesUnconfirmed = 0, batchesSubmitted = 0, batchesSucceeded = 0,
|
||||
notesMerged = 0;
|
||||
std::string firstError;
|
||||
};
|
||||
RunVerdict runVerdict(const RunSummary& s);
|
||||
const char* runWaitKey(RunWait wait); // i18n key of the wait line, nullptr when there is nothing to say
|
||||
|
||||
class ConsolidationRun {
|
||||
public:
|
||||
bool start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now);
|
||||
void requestStop();
|
||||
bool abort();
|
||||
RunStep next(const RunEnv& env, double now);
|
||||
void markCallPosted(int addrIdx, double now);
|
||||
void markPollPosted(double now);
|
||||
// Returns an orphan opid (an accepted merge whose result is stale) for the caller to track, else "".
|
||||
std::string onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind,
|
||||
double callSec, double now);
|
||||
void onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now);
|
||||
bool consumeLockResyncRequest();
|
||||
std::vector<std::string> outstandingOpids() const;
|
||||
void dismiss();
|
||||
bool active() const { return phase_ == Phase::Running; }
|
||||
bool hasRun() const { return phase_ != Phase::Idle; }
|
||||
bool stopping() const { return stopping_; }
|
||||
std::uint64_t gen() const { return gen_; }
|
||||
RunWait lastWait() const { return lastWait_; }
|
||||
RunEnd end() const { return end_; }
|
||||
RunProgress progress() const;
|
||||
RunSummary summary() const;
|
||||
const std::vector<RunAddress>& addresses() const { return addrs_; }
|
||||
const std::vector<RunBatch>& batches() const { return batches_; }
|
||||
const RunConfig& config() const { return cfg_; }
|
||||
|
||||
private:
|
||||
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
||||
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
||||
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
||||
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
|
||||
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
||||
|
||||
Phase phase_ = Phase::Idle;
|
||||
RunEnd end_ = RunEnd::None;
|
||||
RunConfig cfg_;
|
||||
std::vector<RunAddress> addrs_;
|
||||
std::vector<RunBatch> batches_;
|
||||
std::uint64_t gen_ = 0;
|
||||
std::size_t cur_ = 0;
|
||||
int callAddr_ = -1;
|
||||
bool callInFlight_ = false, pollInFlight_ = false, stopping_ = false, halted_ = false, lockResync_ = false;
|
||||
double callPostedAt_ = 0, pollPostedAt_ = 0, nextCallAt_ = 0, nextPollAt_ = 0, syncHoldUntil_ = 0,
|
||||
unlockHoldUntil_ = 0;
|
||||
int deadStreak_ = 0, opFailStreak_ = 0;
|
||||
std::string haltError_;
|
||||
RunWait lastWait_ = RunWait::None;
|
||||
double lastPumpAt_ = 0.0;
|
||||
};
|
||||
|
||||
// ── Scope scan (the Merge/Consolidate dialog's listunspent + z_listunspent) ─────────────────────────
|
||||
// Both calls are whole-wallet scans under the daemon's cs_main. The dialog used to post the pair again on
|
||||
// the frame after any failure, every frame for as long as it stayed open (and forever on Lite, which has
|
||||
|
||||
Reference in New Issue
Block a user