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
+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));