perf(history): write the history snapshot on change, off the UI thread

Every history refresh, address refresh, connect and lock-state check
rewrote the encrypted tx-history snapshot on the UI thread: a JSON
document of every row, its encryption, then INSERT OR REPLACE and a
prune, an estimated 40-120 ms on a 5k-transaction wallet, even when
nothing had changed.

Now a signature of what would be stored (about 1 ms on 5k rows)
decides whether to write. Confirmations count only by the thresholds
the UI reads (conflicted, pending, 1-9, 10-99, 100+); per-address scan
heights, exact confirmations and the tip drift every block and form a
separate part.

- Routine refreshes write a content change at most every 20 s (a
  deferred one is released from the update loop) and the per-block
  drift at most every 10 min. Nothing changed means no write.
- A mining-address relabel, a passphrase change, a rescan, a
  disconnect, a wallet lock and exit write whatever is not on disk yet,
  at once.
- The UI thread only copies the rows and the key into a job; a single
  background writer (newest job wins) builds, encrypts and stores it. A
  failed write is retried.
- Exit queues the final write so it overlaps the daemon stop, then
  waits up to 3 s for the writer; a disk that hangs past that keeps the
  previous snapshot.
- A loaded snapshot counts as stored, so the first identical refresh
  doesn't rewrite it. An address refresh re-keys the snapshot only when
  the address set changed.

After a hard kill the snapshot can now be up to ~20 s behind for rows
and 10 min for scan heights, exact confirmations and the tip (it was
one refresh); the first refresh replaces it either way. Lite builds,
which never read this cache, no longer write it on Refresh or a mining
relabel, and the screenshot capture mode's demo rows never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent c0568eb930
commit 93b6871511
8 changed files with 436 additions and 27 deletions
+100
View File
@@ -18,6 +18,7 @@
#include "util/fetch_backoff.h"
#include "util/logger.h"
#include "data/transaction_history_cache.h"
#include "data/transaction_history_store_policy.h"
#include "util/coalescing_writer.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
@@ -6102,6 +6103,103 @@ void testTransactionHistoryCachePrunesOldWallets()
removeTree(dir);
}
void testHistorySnapshotSignature()
{
using namespace dragonx::data;
using dragonx::TransactionInfo;
auto mk = [](const char* id, double amt, int conf, const char* memo = "") {
return TransactionInfo{id, "receive", amt, 1700000000, conf, "zs-a", "", memo};
};
const std::unordered_set<std::string> none;
const std::unordered_map<std::string, int> h{{"zs-a", 100}};
const std::vector<TransactionInfo> a{mk("t1", 1.0, 3), mk("t2", 2.0, 40)};
const auto base = historySnapshotSignature(a, none, "w", 7, h, 500, "tip");
EXPECT_EQ(base.rows, static_cast<size_t>(2));
// row order is irrelevant
const std::vector<TransactionInfo> reordered{a[1], a[0]};
const auto r = historySnapshotSignature(reordered, none, "w", 7, h, 500, "tip");
EXPECT_EQ(r.content, base.content);
EXPECT_EQ(r.slow, base.slow);
// confirmations inside a bucket move only the slow axis
auto c7 = a; c7[0].confirmations = 7;
const auto s7 = historySnapshotSignature(c7, none, "w", 7, h, 500, "tip");
EXPECT_EQ(s7.content, base.content);
EXPECT_TRUE(s7.slow != base.slow);
// crossing a UI threshold is a content change: <0|0, 0|1, 9|10, 99|100
auto contentAt = [&](int conf) {
auto v = a; v[0].confirmations = conf;
return historySnapshotSignature(v, none, "w", 7, h, 500, "tip").content;
};
EXPECT_TRUE(contentAt(-1) != contentAt(0));
EXPECT_TRUE(contentAt(0) != contentAt(1));
EXPECT_TRUE(contentAt(9) != contentAt(10));
EXPECT_EQ(contentAt(10), contentAt(99));
EXPECT_TRUE(contentAt(99) != contentAt(100));
EXPECT_EQ(contentAt(100), contentAt(5000));
// every persisted field, the wallet hash and the key id are content
auto m = a; m[0].memo = "x";
EXPECT_TRUE(historySnapshotSignature(m, none, "w", 7, h, 500, "tip").content != base.content);
auto ty = a; ty[0].type = "mined";
EXPECT_TRUE(historySnapshotSignature(ty, none, "w", 7, h, 500, "tip").content != base.content);
auto am = a; am[0].amount = 1.0000001;
EXPECT_TRUE(historySnapshotSignature(am, none, "w", 7, h, 500, "tip").content != base.content);
auto fa = a; fa[0].from_address = "zs-other";
EXPECT_TRUE(historySnapshotSignature(fa, none, "w", 7, h, 500, "tip").content != base.content);
EXPECT_TRUE(historySnapshotSignature(a, none, "w2", 7, h, 500, "tip").content != base.content);
EXPECT_TRUE(historySnapshotSignature(a, none, "w", 8, h, 500, "tip").content != base.content);
// the tip only moves the slow axis
const auto t = historySnapshotSignature(a, none, "w", 7, h, 501, "tip2");
EXPECT_EQ(t.content, base.content);
EXPECT_TRUE(t.slow != base.slow);
// pending sends are excluded
auto withPending = a; withPending.push_back(mk("opid-1", -1.0, 0));
const auto p = historySnapshotSignature(withPending, {"opid-1"}, "w", 7, h, 500, "tip");
EXPECT_EQ(p.content, base.content);
EXPECT_EQ(p.slow, base.slow);
EXPECT_EQ(p.rows, static_cast<size_t>(2));
// heights are slow; entries replace() would not persist are ignored
auto h2 = h; h2["zs-a"] = 101;
const auto hs = historySnapshotSignature(a, none, "w", 7, h2, 500, "tip");
EXPECT_EQ(hs.content, base.content);
EXPECT_TRUE(hs.slow != base.slow);
auto h3 = h; h3[""] = 5; h3["zs-neg"] = -1;
EXPECT_EQ(historySnapshotSignature(a, none, "w", 7, h3, 500, "tip").slow, base.slow);
}
void testHistoryStorePolicy()
{
using namespace dragonx::data;
using M = HistoryStoreMode;
using A = HistoryStoreAction;
const HistorySnapshotSig s1{1, 10, 5}, s2{2, 10, 5}, s1slow{1, 11, 5};
auto act = [](M mode, bool have, HistorySnapshotSig stored, HistorySnapshotSig current, double since) {
HistoryStoreInput in;
in.mode = mode;
in.haveStored = have;
in.stored = stored;
in.current = current;
in.sinceLastSubmitSec = since;
return decideHistoryStore(in);
};
EXPECT_TRUE(act(M::Coalesce, true, s1, s1, 1e9) == A::Skip); // nothing changed
EXPECT_TRUE(act(M::Coalesce, true, s1, s2, 5.0) == A::Defer); // changed, too soon
EXPECT_TRUE(act(M::Coalesce, true, s1, s2, kHistoryMinStoreIntervalSec) == A::Store);
EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 1e9) == A::Store); // nothing on disk yet
EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 3.0) == A::Defer); // retry is paced
EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, 100.0) == A::Skip); // slow drift: not yet
EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, kHistorySlowStoreIntervalSec) == A::Store);
EXPECT_TRUE(act(M::Now, true, s1, s1, 0.0) == A::Skip); // clean: no write
EXPECT_TRUE(act(M::Now, true, s1, s1slow, 0.0) == A::Store); // slow drift flushed
EXPECT_TRUE(act(M::Now, true, s1, s2, 0.0) == A::Store); // ignores pacing
EXPECT_TRUE(act(M::Now, false, s1, s1, 0.0) == A::Store);
}
// The background writer's path: a job carries its own key copy (a lockKey() after queueing cannot drop it),
// the copy is wiped, a keyless job is refused, keyId() tracks the key across lock/unlock, and a late job for the
// previous wallet keeps the salt of the wallet unlocked since.
@@ -12800,6 +12898,8 @@ int main()
testExplorerBlockCache();
testTransactionHistoryCache();
testTransactionHistoryCachePrunesOldWallets();
testHistorySnapshotSignature();
testHistoryStorePolicy();
testTransactionHistoryCacheBackgroundWrite();
testAddressBookScope();
testWalletIndex();