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
+2
View File
@@ -1767,6 +1767,8 @@ private:
bool history_sent_verify_pending_ = false; // a loaded watermark owes an anchor verify before its next sweep
int history_full_sweep_failures_ = 0; // whole-wallet sweeps failed in a row (backoff)
std::time_t history_full_sweep_retry_at_ = 0;
std::time_t history_sent_last_full_time_ = 0; // last successful FULL sweep: drives the weekly safety-net
int history_sent_last_full_height_ = -1; // re-sweep (sentSweepSafetyNetDue)
std::unordered_set<std::string> history_enrich_backlog_; // txids found, not enriched yet
double history_addr_scan_ms_ = 0.0; // per-address z_listreceivedbyaddress cost (estimate)
std::size_t history_transparent_rows_ = 0; // rows the last listtransactions chain returned
+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_;
+4
View File
@@ -378,6 +378,8 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
sw.fullFailures = s.value("full_failures", 0);
sw.fullRetryDelaySec = static_cast<long>(s.value("full_retry_delay_s", std::int64_t{0}));
sw.daemonId = s.value("daemon_id", std::string());
sw.lastFullTime = s.value("last_full_time", std::int64_t{0});
sw.lastFullHeight = s.value("last_full_height", -1);
if (s.contains("backlog") && s["backlog"].is_array()) {
for (const auto& txid : s["backlog"])
if (txid.is_string() && !txid.get<std::string>().empty())
@@ -463,6 +465,8 @@ bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
s["full_failures"] = sw.fullFailures;
s["full_retry_delay_s"] = static_cast<std::int64_t>(sw.fullRetryDelaySec);
s["daemon_id"] = sw.daemonId;
s["last_full_time"] = sw.lastFullTime;
s["last_full_height"] = sw.lastFullHeight;
payload["sent_sweep"] = std::move(s);
}
+2
View File
@@ -30,6 +30,8 @@ struct SentSweepState {
int fullFailures = 0;
long fullRetryDelaySec = 0;
std::string daemonId;
std::int64_t lastFullTime = 0; // when the last successful FULL sweep finished: drives the weekly
int lastFullHeight = -1; // safety-net re-sweep that catches changes the per-event guards miss
bool valid() const { return height >= 0; }
};
+5
View File
@@ -65,6 +65,11 @@ bool sentSweepNeedsLongCall(int tip, int fromHeight)
return tip > 0 && fromHeight >= 0 && (tip - fromHeight) > kSentSweepLongCallBlocks;
}
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec)
{
return lastFullSec > 0 && (nowSec - lastFullSec) > kSentSweepSafetyNetSec;
}
long fullSentSweepRetryDelaySec(int consecutiveFailures)
{
if (consecutiveFailures <= 0) return 0;
+7
View File
@@ -15,6 +15,7 @@
#include <nlohmann/json.hpp>
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
@@ -31,6 +32,8 @@ constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99),
constexpr int kSentSweepFirstLoadExtraBlocks = 1000; // E: extra margin on the first sweep after a load,
// to absorb wallet-notifier lag at the last save
constexpr int kSentSweepLongCallBlocks = 2400; // tip - W beyond this: use callLong + count failures
constexpr long kSentSweepSafetyNetSec = 7 * 24 * 3600; // ~weekly full re-sweep: catches external wallet
// changes the per-event guards can't see
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
constexpr int kTransparentPageMin = 1000;
constexpr int kTransparentPageMax = 5000;
@@ -64,6 +67,10 @@ int sentSweepAnchor(int fromHeight, bool firstAfterLoad);
// A watermark saved long ago makes a huge "windowed" call: past this gap, use callLong and count failures
// toward the backoff, so it doesn't time out on every block while the daemon keeps working.
bool sentSweepNeedsLongCall(int tip, int fromHeight);
// A watermarked wallet is due a periodic full re-sweep to catch changes made outside the GUI (a Console
// import, an external same-seed restore) that the per-event guards miss. lastFullSec == 0 (no full sweep
// recorded yet) never triggers one: the seeding full sweep records it.
bool sentSweepSafetyNetDue(std::int64_t nowSec, std::int64_t lastFullSec);
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
long fullSentSweepRetryDelaySec(int consecutiveFailures);
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
+7
View File
@@ -9220,6 +9220,7 @@ void testSentSweepPersistence()
SentSweepState sw;
sw.height = 500; sw.anchorHeight = 380; sw.anchorHash = "ha380";
sw.backlog = {"bt1", "bt2"}; sw.fullFailures = 2; sw.fullRetryDelaySec = 3600; sw.daemonId = "v1.3.0|t";
sw.lastFullTime = 1699990000; sw.lastFullHeight = 420;
EXPECT_TRUE(cache.replace(id, 500, "tip", txs, 1700000000, {}, sw));
auto loaded = cache.load(id, 500, "tip");
EXPECT_TRUE(loaded.loaded);
@@ -9231,6 +9232,8 @@ void testSentSweepPersistence()
EXPECT_EQ(loaded.sentSweep.fullFailures, 2);
EXPECT_EQ(loaded.sentSweep.fullRetryDelaySec, 3600L);
EXPECT_EQ(loaded.sentSweep.daemonId, std::string("v1.3.0|t"));
EXPECT_EQ(loaded.sentSweep.lastFullTime, static_cast<std::int64_t>(1699990000));
EXPECT_EQ(loaded.sentSweep.lastFullHeight, 420);
}
{ // no watermark passed -> reads invalid (W = -1), i.e. today's full-sweep behaviour
TransactionHistoryCache cache(dbPath.string());
@@ -9266,6 +9269,10 @@ void testSentSweepPolicyHelpers()
EXPECT_FALSE(sentSweepNeedsLongCall(1000, 999)); // tiny window
EXPECT_FALSE(sentSweepNeedsLongCall(1000, -1)); // no watermark (full-sweep path)
EXPECT_TRUE(sentSweepNeedsLongCall(10000, 10000 - kSentSweepLongCallBlocks - 1));
EXPECT_FALSE(sentSweepSafetyNetDue(1000, 0)); // no full sweep recorded yet
EXPECT_FALSE(sentSweepSafetyNetDue(1000 + kSentSweepSafetyNetSec, 1000)); // exactly at the threshold
EXPECT_TRUE(sentSweepSafetyNetDue(1000 + kSentSweepSafetyNetSec + 1, 1000)); // past it -> due
EXPECT_FALSE(sentSweepSafetyNetDue(1100, 1000)); // recent full sweep
auto windowed = sentSweepParams("z1", 2000, 1500, sentSweepMargin(true));
EXPECT_EQ(windowed.size(), static_cast<size_t>(4));
EXPECT_EQ(windowed[3].get<int>(), (2000 - 1500) + sentSweepMargin(true));