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:
@@ -690,6 +690,8 @@ void App::tryConnect()
|
||||
void App::onConnected()
|
||||
{
|
||||
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
||||
++rpc_session_;
|
||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||
state_.connected = true;
|
||||
ui::ShieldDialog::invalidateScope(); // inputs read before this connect may be another wallet's
|
||||
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
|
||||
@@ -873,6 +875,8 @@ void App::onDisconnected(const std::string& reason)
|
||||
first_balance_read_tried_ = false;
|
||||
address_owed_next_try_ = {};
|
||||
mempool_known_ = false;
|
||||
++rpc_session_;
|
||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||
ui::ShieldDialog::invalidateScope();
|
||||
address_validation_cache_dirty_ = true;
|
||||
resetTransactionHistoryCacheSession();
|
||||
@@ -1352,6 +1356,17 @@ void App::markWalletMutated(bool forceBalance)
|
||||
network_refresh_.markWalletMutationRefresh(); // Core / Transactions / Addresses due now
|
||||
}
|
||||
|
||||
services::ScanTicket App::beginAdhocWalletScan()
|
||||
{
|
||||
return scan_governor_.beginExplicit(services::ScanStream::Adhoc, currentScanSignals(), scanClockNow(),
|
||||
/*servesRoutine=*/false);
|
||||
}
|
||||
|
||||
void App::endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome)
|
||||
{
|
||||
scan_governor_.complete(ticket, costMs, outcome, scanClockNow());
|
||||
}
|
||||
|
||||
bool App::invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
const std::string& result)
|
||||
{
|
||||
@@ -1511,6 +1526,10 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
|
||||
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
|
||||
return;
|
||||
}
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
if (!isUsingEmbeddedDaemon()) {
|
||||
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
|
||||
return;
|
||||
@@ -5468,6 +5487,167 @@ void App::maybeRemindSeedBackup()
|
||||
// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow
|
||||
// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the
|
||||
// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold.
|
||||
bool App::startConsolidation(const std::vector<services::NoteGroup>& groups, double fee, int batchLimit)
|
||||
{
|
||||
auto& n = ui::Notifications::instance();
|
||||
if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false;
|
||||
if (consolidation_.active()) {
|
||||
n.warning(TR("merge_run_busy"));
|
||||
return false;
|
||||
}
|
||||
if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) {
|
||||
n.warning(TR("merge_run_migration_pending"));
|
||||
return false;
|
||||
}
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_ || !state_.connected || isWalletSwitchInProgress()) {
|
||||
n.error(TR("merge_run_start_failed"));
|
||||
return false;
|
||||
}
|
||||
services::RunConfig cfg;
|
||||
cfg.fee = fee;
|
||||
cfg.limit = batchLimit;
|
||||
if (!consolidation_.start(groups, cfg, services::steadySeconds())) {
|
||||
n.warning(TR("merge_keep_each_select"));
|
||||
return false;
|
||||
}
|
||||
n.success(TR("merge_started"));
|
||||
return true; // syncing, the lock, a busy node and pending sends are the run's own pauses
|
||||
}
|
||||
|
||||
void App::pumpConsolidation()
|
||||
{
|
||||
if (!consolidation_.active()) return;
|
||||
const double now = services::steadySeconds();
|
||||
const services::ScanContext ctx = scanContext();
|
||||
const bool unlockExpired = state_.encrypted && !state_.locked && state_.unlocked_until > 0 &&
|
||||
std::time(nullptr) >= static_cast<std::time_t>(state_.unlocked_until);
|
||||
services::RunEnv env;
|
||||
// Not walletScanReady(): the run is the user's job (no background hold, no connect gate), and it tells its
|
||||
// pauses apart. Not rpc_->isWarmingUp(): that client flag sticks for the session.
|
||||
env.nodeUsable = !isLiteBuild() && !shutting_down_.load() && !daemon_restarting_.load() && worker_ &&
|
||||
ctx.connected && !ctx.warmingUp && !ctx.runtimeRescan && !state_.sync.rescanning;
|
||||
env.busy = ctx.rpcBusy;
|
||||
env.callsAllowed = !ctx.syncing;
|
||||
env.locked = ctx.locked || unlockExpired;
|
||||
env.userSendBusy = hasTransactionSendProgress(); // a send / shield / auto-shield / chat op of ours goes first
|
||||
env.scanBusy = scan_governor_.anyInFlight(); // never stack a merge scan on another GUI scan (both lanes)
|
||||
if ((consolidation_.consumeLockResyncRequest() || unlockExpired) && now >= run_lock_resync_at_) {
|
||||
run_lock_resync_at_ = now + 30.0;
|
||||
refreshEncryptionState();
|
||||
}
|
||||
const services::RunStep step = consolidation_.next(env, now);
|
||||
if (step.action == services::RunAction::Call) postConsolidationCall(step.addrIdx);
|
||||
else if (step.action == services::RunAction::Poll) postConsolidationPoll();
|
||||
else if (step.action == services::RunAction::Finish) finishConsolidation();
|
||||
}
|
||||
|
||||
void App::postConsolidationCall(int addrIdx)
|
||||
{
|
||||
if (!worker_ || !rpc_) return; // before markCallPosted: asked again next update
|
||||
const auto& addrs = consolidation_.addresses();
|
||||
if (addrIdx < 0 || addrIdx >= static_cast<int>(addrs.size())) return;
|
||||
const std::string address = addrs[addrIdx].group.address;
|
||||
const std::uint64_t gen = consolidation_.gen();
|
||||
const std::uint64_t session = rpc_session_;
|
||||
const double fee = consolidation_.config().fee;
|
||||
const int limit = consolidation_.config().limit;
|
||||
const services::ScanTicket ticket = beginAdhocWalletScan(); // GetFilteredNotes: a whole-wallet scan
|
||||
consolidation_.markCallPosted(addrIdx, services::steadySeconds());
|
||||
worker_->post([this, rpc = rpc_.get(), address, addrIdx, gen, fee, limit, session, ticket]() -> rpc::RPCWorker::MainCb {
|
||||
services::MergeCallResult r;
|
||||
rpc::RpcFailureKind kind = rpc::RpcFailureKind::None;
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
try {
|
||||
MaintenanceCallScope maint(*this); // a disconnect aborts it, or refuses it unsent
|
||||
rpc::RPCClient::TraceScope trace("Consolidate / Merge batch");
|
||||
// fromaddresses = [addr], toaddress = addr: the address's notes merge back into it.
|
||||
const nlohmann::json res = rpc->call("z_mergetoaddress",
|
||||
{nlohmann::json::array({address}), address, fee, limit, limit}, services::kRunCallTimeoutSec);
|
||||
services::readMergeCallResult(res, r);
|
||||
} catch (const std::exception& e) {
|
||||
r = services::MergeCallResult{};
|
||||
r.error = e.what();
|
||||
if (r.error.empty()) r.error = "error";
|
||||
kind = services::mergeCallFailureKind(e); // sent but cut off: uncertain, never resent
|
||||
} catch (...) {
|
||||
r = services::MergeCallResult{};
|
||||
r.error = "unknown error";
|
||||
kind = rpc::RpcFailureKind::Other;
|
||||
}
|
||||
const double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||
return [this, gen, addrIdx, r, kind, secs, session, ticket]() {
|
||||
endAdhocWalletScan(ticket, secs * 1000.0, services::chatScanOutcome(r.error.empty(), kind));
|
||||
const std::string orphan = consolidation_.onCallResult(gen, addrIdx, r, kind, secs, services::steadySeconds());
|
||||
if (!orphan.empty() && session == rpc_session_) trackOperation(orphan); // never lose an accepted merge
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::postConsolidationPoll()
|
||||
{
|
||||
if (!worker_ || !rpc_) return;
|
||||
const std::vector<std::string> ids = consolidation_.outstandingOpids();
|
||||
const auto filter = NetworkRefreshService::buildOperationFilterParams(ids);
|
||||
if (!filter) return;
|
||||
const std::set<std::string> wanted(ids.begin(), ids.end());
|
||||
const std::uint64_t gen = consolidation_.gen();
|
||||
consolidation_.markPollPosted(services::steadySeconds());
|
||||
// Not charged: z_getoperationstatus reads only the async ops map. It needs the filter fallback itself: the
|
||||
// shared opid poller only learns that the filter is unsupported when it has ops of its own.
|
||||
worker_->post([this, rpc = rpc_.get(), params = *filter, wanted, gen]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::vector<services::OpReport> reports;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Consolidate / Batch status");
|
||||
nlohmann::json result;
|
||||
if (!opid_filter_unsupported_.load(std::memory_order_relaxed)) {
|
||||
try {
|
||||
result = rpc->call("z_getoperationstatus", params);
|
||||
} catch (const rpc::RpcError& e) {
|
||||
if (e.kind() == rpc::RpcFailureKind::Warmup) throw;
|
||||
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
|
||||
opid_filter_unsupported_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
} else {
|
||||
result = rpc->call("z_getoperationstatus", nlohmann::json::array());
|
||||
}
|
||||
ok = result.is_array();
|
||||
reports = services::parseOpReports(result, wanted);
|
||||
} catch (...) {
|
||||
ok = false;
|
||||
reports.clear();
|
||||
}
|
||||
return [this, gen, ok, reports = std::move(reports)]() {
|
||||
consolidation_.onPollResult(gen, ok, reports, services::steadySeconds());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void App::finishConsolidation()
|
||||
{
|
||||
const services::RunSummary s = consolidation_.summary();
|
||||
// 2E: a batch of user ops re-reads the wallet and forces one balance, at its end.
|
||||
if (s.batchesSucceeded > 0) markWalletMutated(/*forceBalance=*/true);
|
||||
else if (s.batchesSubmitted > 0) noteWalletMutation();
|
||||
std::string err = s.firstError.empty() ? std::string(TR("shield_unknown_error")) : s.firstError;
|
||||
if (err.size() > 160) err = err.substr(0, 160) + "...";
|
||||
auto& n = ui::Notifications::instance();
|
||||
switch (services::runVerdict(s)) {
|
||||
case services::RunVerdict::Clean: n.success(TR("shield_merge_done")); break;
|
||||
case services::RunVerdict::NothingSubmitted: n.error(TR("merge_keep_each_nothing_submitted")); break;
|
||||
case services::RunVerdict::SomeFailed: n.error(std::string(TR("shield_op_failed")) + err); break;
|
||||
case services::RunVerdict::Unconfirmed: n.warning(TR("send_status_unconfirmed")); break;
|
||||
case services::RunVerdict::Stopped: n.info(TR("merge_run_stopped_toast")); break;
|
||||
case services::RunVerdict::Halted: {
|
||||
char b[512];
|
||||
std::snprintf(b, sizeof(b), TR("merge_run_halted_fmt"), err.c_str());
|
||||
n.error(b);
|
||||
break;
|
||||
}
|
||||
default: break; // Aborted is announced where it happens; it never reaches Finish
|
||||
}
|
||||
}
|
||||
|
||||
void App::maybeWarnLargeWallet()
|
||||
{
|
||||
if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat
|
||||
@@ -5660,6 +5840,10 @@ void App::beginCreateSeedWallet()
|
||||
|
||||
void App::showSeedMigrationDialog()
|
||||
{
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
show_seed_migration_ = true;
|
||||
// Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the
|
||||
// confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start
|
||||
@@ -5765,6 +5949,10 @@ void App::refreshSeedMigrationBalance()
|
||||
// huge-input wallet may leave a small remainder in the (preserved) legacy backup.
|
||||
void App::beginSweepToSeedWallet()
|
||||
{
|
||||
if (consolidation_.active()) {
|
||||
ui::Notifications::instance().warning(TR("merge_run_busy"));
|
||||
return;
|
||||
}
|
||||
if (!rpc_ || !worker_ || !state_.connected) {
|
||||
seed_migration_status_ = "Not connected to the daemon.";
|
||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||
|
||||
Reference in New Issue
Block a user