perf(history): weekly safety-net full re-sweep for sent history

Persisting the sent-sweep watermark means a relaunch runs a windowed sweep,
which can't see a change made outside the GUI (a Console z_importviewingkey,
an external same-seed restore) that adds an old send below the window. This
adds a periodic full re-sweep so that case is still caught within ~7 days.

- sentSweepSafetyNetDue(now, lastFull) = lastFull > 0 && now - lastFull > 7d
  (a wallet that has never full-swept doesn't trigger one; the seeding sweep
  records the time). lastFullTime / lastFullHeight ride in the sent_sweep
  snapshot; any successful full sweep (seed, verify-mismatch or safety-net)
  resets the clock. On load, a valid watermark without a clock starts it now,
  so even watermarks seeded before this field eventually re-sweep.
- When due, the snapshot forces a full sweep for that cycle. Gated on: a
  watermark exists, a routine (non-syncing) cycle, not the Mining page, and
  not in full-sweep backoff -- so a full sweep that can't finish on a slow
  daemon backs off instead of retrying every cycle.

Degrades gracefully: ~0.4 s on a note-cache daemon; callLong(120 s) with
backoff on an old one (the deferred banding would keep each cs_main hold
short so it fits there too). Helper + persistence covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 17:56:30 -05:00
co-authored by Claude Opus 4.8
parent e28cacbe8e
commit 07437fb447
7 changed files with 49 additions and 3 deletions
+22 -3
View File
@@ -2023,6 +2023,12 @@ void App::loadTransactionHistoryCacheIfAvailable()
history_full_sweep_failures_ = loaded.sentSweep.fullFailures;
history_full_sweep_retry_at_ = loaded.sentSweep.fullRetryDelaySec > 0
? std::time(nullptr) + loaded.sentSweep.fullRetryDelaySec : 0;
history_sent_last_full_time_ = loaded.sentSweep.lastFullTime;
history_sent_last_full_height_ = loaded.sentSweep.lastFullHeight;
// Start the safety-net clock if a watermark came back without one (an older cache, or one seeded before
// this field existed), so a windowed-only wallet still gets a full re-sweep ~7 days from now.
if (history_sent_last_full_time_ == 0 && loaded.sentSweep.valid())
history_sent_last_full_time_ = std::time(nullptr);
++history_sent_epoch_; // the loaded watermark replaces any in-flight sweep's result
transaction_history_cache_loaded_ = true;
// What is on disk is exactly what was just loaded: the first identical refresh must not rewrite it.
@@ -2109,6 +2115,8 @@ void App::storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode mode)
job->sentSweep.fullRetryDelaySec = history_full_sweep_retry_at_ > nowSec
? static_cast<long>(history_full_sweep_retry_at_ - nowSec) : 0;
}
job->sentSweep.lastFullTime = history_sent_last_full_time_;
job->sentSweep.lastFullHeight = history_sent_last_full_height_;
auto* cache = &transaction_history_cache_; // App member: outlives every job (see app.h)
history_writer_.submit("tx-history", [cache, job]() { return cache->writeSnapshot(std::move(*job)); });
@@ -2811,8 +2819,15 @@ void App::startTransactionCycle(bool full, bool routineAllowed, services::ScanTi
if (!chatZ.empty() && listed(chatZ)) transactionSnapshot.priorityScanFloors[chatZ] = currentBlocks;
}
}
transactionSnapshot.sentScanFromHeight = history_sent_scan_height_;
transactionSnapshot.sentVerifyPending = history_sent_verify_pending_;
// Weekly safety net: on a watermarked wallet, force a full re-sweep now and then so a change made
// outside the GUI (a Console import, an external same-seed restore) that the per-event guards can't
// see is still caught within ~7 days. Not on the Mining page; needs a routine (non-syncing) cycle.
const bool sentSafetyNetDue = routineAllowed && history_sent_scan_height_ >= 0 &&
current_page_ != ui::NavPage::Mining &&
std::time(nullptr) >= history_full_sweep_retry_at_ && // a failed full sweep backs off, not retries
services::sentSweepSafetyNetDue(std::time(nullptr), history_sent_last_full_time_);
transactionSnapshot.sentScanFromHeight = sentSafetyNetDue ? -1 : history_sent_scan_height_;
transactionSnapshot.sentVerifyPending = history_sent_verify_pending_ && !sentSafetyNetDue;
transactionSnapshot.sentVerifyAnchorHeight = history_sent_anchor_height_;
transactionSnapshot.sentVerifyAnchorHash = history_sent_anchor_hash_;
// A windowed sweep mid-pass too, now and then, so an untracked send (the Console, another wallet on
@@ -2826,7 +2841,7 @@ void App::startTransactionCycle(bool full, bool routineAllowed, services::ScanTi
// and backs off after whole-wallet sweeps that failed.
transactionSnapshot.skipSentSweep =
(!routineAllowed && initial_history_scan_complete_) ||
(history_sent_scan_height_ >= 0 && history_sent_scan_height_ == currentBlocks) ||
(!sentSafetyNetDue && history_sent_scan_height_ >= 0 && history_sent_scan_height_ == currentBlocks) ||
(history_sent_scan_height_ < 0 && std::time(nullptr) < history_full_sweep_retry_at_);
} else {
// Light: rows now (a send, a mutation), no wallet-wide z-scans.
@@ -2887,6 +2902,10 @@ void App::startTransactionCycle(bool full, bool routineAllowed, services::ScanTi
history_sent_verify_pending_ = false; // this sweep covered the window; nothing owed
history_full_sweep_failures_ = 0;
history_full_sweep_retry_at_ = 0;
if (result.sentSweepFull) { // a full sweep resets the safety-net clock
history_sent_last_full_time_ = now;
history_sent_last_full_height_ = result.sentSweepHeight;
}
} else if (result.sentSweepRan && result.sentSweepFull &&
services::fullSentSweepFailureCounts(result.sentSweepErrKind)) {
++history_full_sweep_failures_;