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
+18 -1
View File
@@ -19,6 +19,7 @@
#include <optional>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/transaction_history_store_policy.h"
#include "data/address_book.h"
#include "data/wallet_index.h"
#include "data/wallet_state.h"
@@ -864,7 +865,12 @@ private:
// rescan-completion sites so a new witness field can't be forgotten in one copy.
void resetWitnessRescanProgress();
void loadTransactionHistoryCacheIfAvailable();
void storeTransactionHistoryCacheIfAvailable();
// Persist the tx-history snapshot. Coalesce: only a change that matters, rate-limited (content <= every
// 20 s, scan heights/confirmations/tip <= every 10 min). Now: anything not yet on disk, immediately (user
// action, disconnect, lock, exit). The write itself runs on history_writer_; this only copies the rows.
void storeTransactionHistoryCacheIfAvailable(
data::HistoryStoreMode mode = data::HistoryStoreMode::Coalesce);
void pumpTransactionHistoryStore(); // update(): release a deferred store, retry a failed write
void wipePendingTransactionHistoryCachePassphrase();
void resetTransactionHistoryCacheSession();
// The wallet locked: drop the cache key, keep the history and its scan progress (an unlock used to
@@ -1785,6 +1791,17 @@ private:
data::WalletIndex wallet_index_; // per-wallet metadata cache (wallets.json); populated after each load
std::string pending_transaction_history_cache_passphrase_;
bool transaction_history_cache_loaded_ = false;
// Background writer for the history snapshot. Declared AFTER transaction_history_cache_ (destroyed first,
// so a destructor-only teardown joins it before the cache goes); its jobs reference only that cache.
// App::shutdown() drains it (bounded) and joins it once idle; that is the real join (main.cpp _Exit()s past
// ~App()).
util::CoalescingWriter history_writer_;
struct HistoryStoreState {
bool have = false; // a snapshot of this session is on disk (submitted or loaded)
data::HistorySnapshotSig sig; // its signature
std::chrono::steady_clock::time_point lastSubmit{};
} history_store_; // UI thread only
bool history_store_pending_ = false; // a content change waits out kHistoryMinStoreIntervalSec
// Lock screen state
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus