"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>
650 lines
23 KiB
C++
650 lines
23 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "note_consolidation.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <climits>
|
|
#include <map>
|
|
#include <set>
|
|
|
|
namespace dragonx {
|
|
namespace services {
|
|
|
|
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
|
{
|
|
std::map<std::string, NoteGroup> byAddress;
|
|
if (zListUnspent.is_array()) {
|
|
for (const auto& n : zListUnspent) {
|
|
if (!n.is_object()) continue;
|
|
const std::string address = n.value("address", std::string());
|
|
if (address.empty()) continue;
|
|
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
|
|
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
|
|
if (noteIsLocked(n)) continue; // held by an operation in flight
|
|
NoteGroup& g = byAddress[address];
|
|
g.address = address;
|
|
++g.notes;
|
|
g.amount += n.value("amount", 0.0);
|
|
}
|
|
}
|
|
std::vector<NoteGroup> out;
|
|
out.reserve(byAddress.size());
|
|
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
|
|
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
|
|
if (a.notes != b.notes) return a.notes > b.notes;
|
|
return a.address < b.address;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
bool noteIsLocked(const nlohmann::json& zListUnspentEntry)
|
|
{
|
|
if (!zListUnspentEntry.is_object()) return false;
|
|
const auto it = zListUnspentEntry.find("locked");
|
|
return it != zListUnspentEntry.end() && it->is_boolean() && it->get<bool>();
|
|
}
|
|
|
|
bool worthConsolidating(const NoteGroup& group, double fee)
|
|
{
|
|
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
|
|
return group.notes >= 2 && group.amount > 2.0 * fee;
|
|
}
|
|
|
|
int estimateBatches(int notes, int limit)
|
|
{
|
|
if (notes < 2) return 0;
|
|
if (limit < 2) limit = 2;
|
|
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
|
}
|
|
|
|
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches)
|
|
{
|
|
if (!r.error.empty()) {
|
|
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
|
return false;
|
|
}
|
|
run.opids.push_back(r.opid);
|
|
run.notesMerged += r.merging;
|
|
run.remaining = r.remaining;
|
|
// One leftover note can't be merged into its own address.
|
|
return r.remaining >= 2 && static_cast<int>(run.opids.size()) < maxBatches;
|
|
}
|
|
|
|
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
|
|
{
|
|
AddressRun run;
|
|
run.address = address;
|
|
if (maxBatches <= 0) return run;
|
|
while (recordMergeResult(run, call(address), maxBatches)) {}
|
|
return run;
|
|
}
|
|
|
|
namespace {
|
|
std::string lower(std::string s)
|
|
{
|
|
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
return s;
|
|
}
|
|
bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; }
|
|
|
|
int clampedCount(const nlohmann::json& obj, const char* key)
|
|
{
|
|
const auto it = obj.find(key);
|
|
if (it == obj.end()) return 0;
|
|
if (it->is_number_unsigned()) {
|
|
const auto v = it->get<std::uint64_t>();
|
|
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
|
|
}
|
|
if (it->is_number_integer()) {
|
|
const auto v = it->get<std::int64_t>();
|
|
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(v));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::string stringField(const nlohmann::json& obj, const char* key)
|
|
{
|
|
const auto it = obj.find(key);
|
|
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
|
|
}
|
|
} // namespace
|
|
|
|
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out)
|
|
{
|
|
out = MergeCallResult{};
|
|
if (res.is_object()) {
|
|
out.opid = stringField(res, "opid");
|
|
out.merging = clampedCount(res, "mergingNotes");
|
|
out.remaining = clampedCount(res, "remainingNotes");
|
|
}
|
|
if (out.opid.empty()) {
|
|
out = MergeCallResult{};
|
|
out.error = "no operation id";
|
|
}
|
|
}
|
|
|
|
OpState opStateFromStatus(const std::string& status)
|
|
{
|
|
if (status == "queued") return OpState::Queued;
|
|
if (status == "executing") return OpState::Executing;
|
|
if (status == "success") return OpState::Success;
|
|
if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed;
|
|
return OpState::Unknown;
|
|
}
|
|
|
|
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
|
|
{
|
|
std::vector<OpReport> out;
|
|
if (!statusArray.is_array()) return out;
|
|
for (const auto& op : statusArray) {
|
|
if (!op.is_object()) continue;
|
|
OpReport rep;
|
|
rep.id = stringField(op, "id");
|
|
if (rep.id.empty()) continue;
|
|
if (!wanted.empty() && !wanted.count(rep.id)) continue;
|
|
rep.status = stringField(op, "status");
|
|
const auto err = op.find("error");
|
|
if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message");
|
|
const auto res = op.find("result");
|
|
if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid");
|
|
out.push_back(std::move(rep));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message)
|
|
{
|
|
using K = rpc::RpcFailureKind;
|
|
switch (kind) {
|
|
case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge
|
|
case K::Deferred:
|
|
case K::NotListening:
|
|
case K::ConnectTimeout:
|
|
case K::Warmup:
|
|
case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran
|
|
case K::ReplyTimeout: // the daemon keeps running it and may queue the merge
|
|
case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale)
|
|
return MergeFailure::Uncertain;
|
|
case K::ShuttingDown:
|
|
case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it
|
|
case K::Auth: return MergeFailure::Fatal;
|
|
default: break; // AppError, Other: by message
|
|
}
|
|
const std::string m = lower(message);
|
|
if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending
|
|
if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap)
|
|
if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock;
|
|
if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued"
|
|
if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") ||
|
|
has(m, "greater than the net amount"))
|
|
return MergeFailure::NothingLeft;
|
|
return MergeFailure::Fatal;
|
|
}
|
|
|
|
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
|
{
|
|
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
|
if (kind == rpc::RpcFailureKind::Other) {
|
|
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e); t && t->requestSent && t->httpStatus == 0)
|
|
return rpc::RpcFailureKind::ReplyTimeout;
|
|
}
|
|
return kind;
|
|
}
|
|
|
|
RunVerdict runVerdict(const RunSummary& s)
|
|
{
|
|
switch (s.end) {
|
|
case RunEnd::None: return RunVerdict::None;
|
|
case RunEnd::Stopped: return RunVerdict::Stopped;
|
|
case RunEnd::Halted: return RunVerdict::Halted;
|
|
case RunEnd::Aborted: return RunVerdict::Aborted;
|
|
case RunEnd::Completed: break;
|
|
}
|
|
if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted;
|
|
if (s.addressesFailed > 0) return RunVerdict::SomeFailed;
|
|
if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed;
|
|
return RunVerdict::Clean;
|
|
}
|
|
|
|
const char* runWaitKey(RunWait wait)
|
|
{
|
|
switch (wait) {
|
|
case RunWait::Node:
|
|
case RunWait::Busy: return "merge_waiting_node";
|
|
case RunWait::Syncing: return "merge_run_wait_sync";
|
|
case RunWait::Locked: return "merge_wait_locked";
|
|
case RunWait::UserSend: return "merge_run_wait_send";
|
|
case RunWait::Scan:
|
|
case RunWait::Batch: return "merge_run_wait_batch";
|
|
case RunWait::None:
|
|
case RunWait::InFlight: break;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool ConsolidationRun::start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now)
|
|
{
|
|
if (phase_ == Phase::Running) return false;
|
|
cfg.limit = std::max(2, cfg.limit);
|
|
cfg.maxBatches = std::max(1, cfg.maxBatches);
|
|
std::vector<RunAddress> addrs;
|
|
for (const auto& g : groups) {
|
|
if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue;
|
|
RunAddress a;
|
|
a.group = g;
|
|
a.run.address = g.address;
|
|
addrs.push_back(std::move(a));
|
|
}
|
|
if (addrs.empty()) return false;
|
|
const std::uint64_t gen = gen_ + 1;
|
|
*this = ConsolidationRun();
|
|
gen_ = gen;
|
|
cfg_ = cfg;
|
|
addrs_ = std::move(addrs);
|
|
phase_ = Phase::Running;
|
|
nextCallAt_ = nextPollAt_ = now;
|
|
lastPumpAt_ = now;
|
|
return true;
|
|
}
|
|
|
|
void ConsolidationRun::requestStop()
|
|
{
|
|
if (phase_ == Phase::Running) stopping_ = true;
|
|
}
|
|
|
|
void ConsolidationRun::dismiss()
|
|
{
|
|
if (phase_ == Phase::Running) return;
|
|
const std::uint64_t gen = gen_; // a stale result can never match a later run
|
|
*this = ConsolidationRun();
|
|
gen_ = gen;
|
|
}
|
|
|
|
bool ConsolidationRun::abort()
|
|
{
|
|
if (phase_ != Phase::Running) return false;
|
|
++gen_;
|
|
if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size())) {
|
|
auto& a = addrs_[callAddr_];
|
|
a.done = true;
|
|
worsen(a, AddrOutcome::Uncertain);
|
|
}
|
|
callInFlight_ = pollInFlight_ = false;
|
|
for (auto& b : batches_) {
|
|
if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed"
|
|
}
|
|
phase_ = Phase::Finished;
|
|
end_ = RunEnd::Aborted;
|
|
lastWait_ = RunWait::None;
|
|
return true;
|
|
}
|
|
|
|
void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg)
|
|
{
|
|
a.done = true;
|
|
worsen(a, o);
|
|
if (a.run.error.empty()) a.run.error = msg;
|
|
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
|
}
|
|
|
|
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
|
|
{
|
|
b.state = st;
|
|
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
|
RunAddress& a = addrs_[b.addrIdx];
|
|
switch (st) {
|
|
case OpState::Success:
|
|
if (rep) b.txid = rep->txid;
|
|
++a.opsDone;
|
|
opFailStreak_ = 0;
|
|
break;
|
|
case OpState::Failed:
|
|
if (rep) b.error = rep->error;
|
|
if (a.failure.empty()) a.failure = b.error;
|
|
a.done = true;
|
|
worsen(a, AddrOutcome::Failed);
|
|
if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error);
|
|
break;
|
|
case OpState::Lost:
|
|
a.done = true;
|
|
worsen(a, AddrOutcome::Uncertain);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
RunStep ConsolidationRun::next(const RunEnv& env, double now)
|
|
{
|
|
if (phase_ != Phase::Running) return {};
|
|
if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan
|
|
callInFlight_ = false;
|
|
++gen_;
|
|
if (callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size()))
|
|
endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, "");
|
|
nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec);
|
|
}
|
|
if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) {
|
|
pollInFlight_ = false;
|
|
++gen_;
|
|
}
|
|
// An op the daemon never reports done is presumed lost after an hour in which it could have been polled
|
|
// (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened.
|
|
const double dt = std::max(0.0, now - lastPumpAt_);
|
|
lastPumpAt_ = now;
|
|
const bool pollable = env.nodeUsable && !env.busy;
|
|
for (auto& b : batches_) {
|
|
if (opStateTerminal(b.state)) continue;
|
|
if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt));
|
|
if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr);
|
|
}
|
|
while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_;
|
|
const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size();
|
|
const int outstanding = static_cast<int>(std::count_if(batches_.begin(), batches_.end(),
|
|
[](const RunBatch& b) { return !opStateTerminal(b.state); }));
|
|
auto wait = [&](RunWait w) {
|
|
lastWait_ = w;
|
|
return RunStep{};
|
|
};
|
|
if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) {
|
|
phase_ = Phase::Finished;
|
|
end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed;
|
|
lastWait_ = RunWait::None;
|
|
return {RunAction::Finish, -1}; // exactly once
|
|
}
|
|
if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time
|
|
if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too
|
|
if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main
|
|
if (outstanding > 0 && now >= nextPollAt_) {
|
|
lastWait_ = RunWait::None;
|
|
return {RunAction::Poll, -1};
|
|
}
|
|
if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches
|
|
if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing);
|
|
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
|
if (env.userSendBusy) return wait(RunWait::UserSend);
|
|
if (now < nextCallAt_) return wait(RunWait::Batch);
|
|
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
|
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
|
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
|
lastWait_ = RunWait::None;
|
|
return {RunAction::Call, static_cast<int>(cur_)};
|
|
}
|
|
|
|
void ConsolidationRun::markCallPosted(int addrIdx, double now)
|
|
{
|
|
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(addrs_.size())) return;
|
|
callInFlight_ = true;
|
|
callAddr_ = addrIdx;
|
|
callPostedAt_ = now;
|
|
worsen(addrs_[addrIdx], AddrOutcome::Running);
|
|
}
|
|
|
|
void ConsolidationRun::markPollPosted(double now)
|
|
{
|
|
if (phase_ != Phase::Running) return;
|
|
pollInFlight_ = true;
|
|
pollPostedAt_ = now;
|
|
}
|
|
|
|
std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r,
|
|
rpc::RpcFailureKind kind, double callSec, double now)
|
|
{
|
|
const bool accepted = r.error.empty() && !r.opid.empty();
|
|
if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_)
|
|
return accepted ? r.opid : std::string();
|
|
callInFlight_ = false;
|
|
const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec);
|
|
nextCallAt_ = gapAt;
|
|
RunAddress& a = addrs_[addrIdx];
|
|
if (accepted) {
|
|
a.transient = 0;
|
|
deadStreak_ = 0;
|
|
const bool more = recordMergeResult(a.run, r, cfg_.maxBatches);
|
|
RunBatch b;
|
|
b.addrIdx = addrIdx;
|
|
b.opid = r.opid;
|
|
b.merging = r.merging;
|
|
b.acceptedAt = now;
|
|
batches_.push_back(std::move(b));
|
|
nextPollAt_ = now; // learn its state before the next call
|
|
if (!more) {
|
|
a.done = true;
|
|
worsen(a, AddrOutcome::Done);
|
|
}
|
|
return {};
|
|
}
|
|
const std::string msg = r.error.empty() ? std::string("no operation id") : r.error;
|
|
switch (classifyMergeFailure(kind, msg)) {
|
|
case MergeFailure::NotSent: // the same batch again, no retry budget
|
|
nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec);
|
|
break;
|
|
case MergeFailure::Syncing: // no retry budget
|
|
syncHoldUntil_ = now + kRunSyncHoldSec;
|
|
break;
|
|
case MergeFailure::NeedsUnlock:
|
|
if (++a.unlockRefusals >= kRunMaxUnlockRefusals) {
|
|
endAddress(a, AddrOutcome::Failed, msg);
|
|
} else {
|
|
unlockHoldUntil_ = now + kRunUnlockHoldSec;
|
|
lockResync_ = true;
|
|
}
|
|
break;
|
|
case MergeFailure::NothingLeft:
|
|
if (a.run.opids.empty()) {
|
|
endAddress(a, AddrOutcome::Failed, msg);
|
|
} else {
|
|
a.done = true;
|
|
worsen(a, AddrOutcome::Done);
|
|
}
|
|
break;
|
|
case MergeFailure::Uncertain: // never resent
|
|
endAddress(a, AddrOutcome::Uncertain, msg);
|
|
break;
|
|
case MergeFailure::Transient:
|
|
if (++a.transient > kRunMaxTransientRetries) {
|
|
endAddress(a, AddrOutcome::Failed, msg);
|
|
} else {
|
|
nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast<double>(1 << (a.transient - 1))));
|
|
}
|
|
break;
|
|
case MergeFailure::Fatal:
|
|
endAddress(a, AddrOutcome::Failed, msg);
|
|
break;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now)
|
|
{
|
|
if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return;
|
|
pollInFlight_ = false;
|
|
nextPollAt_ = now + kRunPollIntervalSec;
|
|
if (!ok) return;
|
|
// No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about.
|
|
std::map<std::string, const OpReport*> byId;
|
|
for (const auto& rep : reports) byId[rep.id] = &rep;
|
|
for (auto& b : batches_) {
|
|
if (opStateTerminal(b.state)) continue;
|
|
const auto it = byId.find(b.opid);
|
|
if (it == byId.end()) {
|
|
if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr);
|
|
continue;
|
|
}
|
|
b.misses = 0;
|
|
const OpState st = opStateFromStatus(it->second->status);
|
|
if (st != OpState::Unknown) applyOpState(b, st, it->second);
|
|
}
|
|
}
|
|
|
|
bool ConsolidationRun::consumeLockResyncRequest()
|
|
{
|
|
const bool r = lockResync_;
|
|
lockResync_ = false;
|
|
return r;
|
|
}
|
|
|
|
std::vector<std::string> ConsolidationRun::outstandingOpids() const
|
|
{
|
|
std::vector<std::string> ids;
|
|
for (const auto& b : batches_) {
|
|
if (!opStateTerminal(b.state)) ids.push_back(b.opid);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
RunProgress ConsolidationRun::progress() const
|
|
{
|
|
RunProgress p;
|
|
p.addresses = static_cast<int>(addrs_.size());
|
|
for (const auto& a : addrs_) {
|
|
if (a.done) ++p.addressesDone;
|
|
const int submitted = static_cast<int>(a.run.opids.size());
|
|
p.batchesEstimated += a.done ? submitted
|
|
: std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted);
|
|
}
|
|
p.batchesSubmitted = static_cast<int>(batches_.size());
|
|
for (const auto& b : batches_) {
|
|
if (opStateTerminal(b.state)) ++p.batchesDone;
|
|
if (b.state == OpState::Success) p.notesMerged += b.merging;
|
|
}
|
|
p.stopping = stopping_;
|
|
p.wait = lastWait_;
|
|
return p;
|
|
}
|
|
|
|
RunSummary ConsolidationRun::summary() const
|
|
{
|
|
RunSummary s;
|
|
s.end = end_;
|
|
s.addresses = static_cast<int>(addrs_.size());
|
|
for (const auto& a : addrs_) {
|
|
if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed;
|
|
if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed;
|
|
}
|
|
s.batchesSubmitted = static_cast<int>(batches_.size());
|
|
for (const auto& b : batches_) {
|
|
if (b.state == OpState::Success) {
|
|
++s.batchesSucceeded;
|
|
s.notesMerged += b.merging;
|
|
}
|
|
}
|
|
s.firstError = haltError_;
|
|
if (s.firstError.empty()) {
|
|
for (const auto& a : addrs_) {
|
|
if (a.outcome != AddrOutcome::Failed) continue;
|
|
s.firstError = !a.failure.empty() ? a.failure : a.run.error;
|
|
if (!s.firstError.empty()) break;
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
|
|
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
|
|
{
|
|
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
|
|
if (scan.inFlight || in.busyWithOp) return ScopeWant::Nothing;
|
|
if (scan.phase == ScopePhase::Failed && !scan.autoRetry) return ScopeWant::Nothing; // waits for Retry
|
|
if (in.now < scan.retryAt) return ScopeWant::WaitBackoff;
|
|
if (!in.nodeReady) return ScopeWant::WaitNode;
|
|
return ScopeWant::Post;
|
|
}
|
|
|
|
std::uint32_t scopeScanBegin(ScopeScan& scan, double now)
|
|
{
|
|
scan.phase = ScopePhase::Loading;
|
|
scan.inFlight = true;
|
|
scan.inFlightGen = scan.gen;
|
|
scan.startedAt = now;
|
|
return scan.gen;
|
|
}
|
|
|
|
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
|
|
double now)
|
|
{
|
|
if (scan.inFlight && gen == scan.inFlightGen) scan.inFlight = false;
|
|
if (gen != scan.gen) return false; // a scan from an earlier session: its data is not this one's
|
|
if (error.empty()) {
|
|
scan.phase = ScopePhase::Loaded;
|
|
scan.failures = 0;
|
|
scan.autoRetry = false;
|
|
scan.retryAt = 0.0;
|
|
scan.error.clear();
|
|
return true;
|
|
}
|
|
scan.phase = ScopePhase::Failed;
|
|
++scan.failures;
|
|
scan.error = error;
|
|
scan.autoRetry = scopeFailureRetries(kind, error) && scan.failures <= kScopeMaxAutoRetries;
|
|
scan.retryAt = scan.autoRetry ? now + scopeRetryDelaySec(scan.failures) : 0.0;
|
|
return false;
|
|
}
|
|
|
|
void scopeScanOpen(ScopeScan& scan, double now)
|
|
{
|
|
if (scan.inFlight && now - scan.startedAt > kScopeInFlightMaxSec) scan.inFlight = false; // lost
|
|
if (scan.inFlight && scan.phase == ScopePhase::Loading) return; // still loading for the last session
|
|
++scan.gen;
|
|
scan.phase = ScopePhase::Idle;
|
|
scan.failures = 0;
|
|
scan.autoRetry = false;
|
|
scan.retryAt = 0.0;
|
|
scan.error.clear();
|
|
}
|
|
|
|
void scopeScanInvalidate(ScopeScan& scan)
|
|
{
|
|
++scan.gen;
|
|
scan.phase = ScopePhase::Idle;
|
|
scan.failures = 0;
|
|
scan.autoRetry = false;
|
|
scan.retryAt = 0.0;
|
|
scan.error.clear();
|
|
}
|
|
|
|
void scopeScanRetry(ScopeScan& scan)
|
|
{
|
|
if (scan.phase != ScopePhase::Failed) return;
|
|
scan.phase = ScopePhase::Idle;
|
|
scan.failures = 0;
|
|
scan.autoRetry = false;
|
|
scan.retryAt = 0.0;
|
|
scan.error.clear();
|
|
}
|
|
|
|
double scopeRetryDelaySec(int failures)
|
|
{
|
|
if (failures <= 1) return 10.0;
|
|
if (failures == 2) return 30.0;
|
|
return 60.0;
|
|
}
|
|
|
|
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message)
|
|
{
|
|
using K = rpc::RpcFailureKind;
|
|
switch (kind) {
|
|
case K::NotListening:
|
|
case K::ConnectTimeout:
|
|
case K::Warmup:
|
|
case K::Deferred:
|
|
case K::ShuttingDown:
|
|
case K::Unhandled:
|
|
case K::Aborted:
|
|
return true;
|
|
case K::Other: {
|
|
std::string lower = message;
|
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
return lower.find("not connected") != std::string::npos;
|
|
}
|
|
default:
|
|
return false; // ReplyTimeout, QueueFull, AppError, Auth, parse/type errors
|
|
}
|
|
}
|
|
|
|
} // namespace services
|
|
} // namespace dragonx
|