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
+3
View File
@@ -496,6 +496,7 @@ set(APP_SOURCES
src/wallet/lite_wallet_lifecycle_service.cpp
src/data/wallet_state.cpp
src/data/transaction_history_cache.cpp
src/data/transaction_history_store_policy.cpp
src/ui/theme.cpp
src/ui/theme_loader.cpp
src/ui/explorer/explorer_block_cache.cpp
@@ -651,6 +652,7 @@ set(APP_HEADERS
src/config/version.h
src/data/wallet_state.h
src/data/transaction_history_cache.h
src/data/transaction_history_store_policy.h
src/ui/theme.h
src/ui/theme_loader.h
src/ui/explorer/explorer_block_cache.h
@@ -1207,6 +1209,7 @@ if(BUILD_TESTING)
src/util/text_format.cpp
src/data/wallet_state.cpp
src/data/transaction_history_cache.cpp
src/data/transaction_history_store_policy.cpp
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
+14
View File
@@ -849,6 +849,10 @@ void App::update()
}
}
// Release a deferred history-snapshot store / retry a failed write (O(1) when idle). After the drains, so
// a store a callback deferred this tick is seen; capture_mode_ returned earlier, so never during a sweep.
pumpTransactionHistoryStore();
// Apply any lite-wallet refresh the controller's background worker produced (main thread).
if (lite_wallet_) {
// Finalize any completed async open on the main thread (flips walletOpen / surfaces failure).
@@ -6205,6 +6209,8 @@ void App::beginShutdown()
stop_user_choice_ = 0;
stop_kill_offered_ = false;
shutdown_start_time_ = std::chrono::steady_clock::now();
// Queue the final tx-history snapshot now so its write overlaps the daemon stop; shutdown() drains it.
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
async_tasks_.cancelAll();
// The seed-migration adopt task drives daemon stop/start and swaps wallet.dat; let it finish
// before shutdown touches the daemon, so the two never race. shutting_down_ is already set, so
@@ -7317,6 +7323,14 @@ void App::renderLoadingOverlay(float contentH)
void App::shutdown()
{
// Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path),
// then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write
// is left running (one autocommit INSERT in WAL mode: a cut-off write keeps the previous snapshot) and _Exit
// ends it. Lite: the store returns at once and the writer never started.
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
if (history_writer_.flush(std::chrono::seconds(3))) history_writer_.stop();
else DEBUG_LOGF("[History] snapshot write still running at exit - left to finish (WAL keeps the old one)\n");
// Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the
// normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown.
stopMiningControlThread();
+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
+104 -25
View File
@@ -802,6 +802,10 @@ void App::onConnected()
void App::onDisconnected(const std::string& reason)
{
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
// session reset below empty them, which would turn a later flush into a silent no-op.
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
state_.connected = false;
state_.warming_up = false;
state_.warmup_status.clear();
@@ -1753,15 +1757,22 @@ void App::wipePendingTransactionHistoryCachePassphrase()
void App::resetTransactionHistoryCacheSession()
{
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // while the key is still live
transaction_history_cache_.lockKey();
wipePendingTransactionHistoryCachePassphrase();
transaction_history_cache_loaded_ = false;
history_store_ = {}; // a new session: nothing of it is known to be on disk yet
history_store_pending_ = false;
invalidateShieldedHistoryScanProgress(false);
history_listed_z_.clear();
}
void App::lockTransactionHistoryCacheSession()
{
// Write what is not on disk yet while the key is live. The queued job carries its own key copy, so the
// lockKey() below cannot lose it. history_store_ is kept: the snapshot stays valid, and re-unlocking with
// the same passphrase gives the same keyId(), so the unlock does not rewrite it.
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
transaction_history_cache_.lockKey();
wipePendingTransactionHistoryCachePassphrase();
transaction_history_cache_loaded_ = false;
@@ -1801,7 +1812,7 @@ void App::invalidateShieldedHistoryScanProgress(bool persistCache, bool keepSent
history_full_sweep_retry_at_ = 0;
++history_sent_epoch_;
}
if (persistCache) storeTransactionHistoryCacheIfAvailable();
if (persistCache) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // the cleared heights must reach disk
}
std::vector<std::string> App::shieldedAddressList() const
@@ -1919,38 +1930,104 @@ void App::loadTransactionHistoryCacheIfAvailable()
}
confirmed_cache_block_ = loaded.tipHeight;
transaction_history_cache_loaded_ = true;
// What is on disk is exactly what was just loaded: the first identical refresh must not rewrite it.
history_store_.sig = data::historySnapshotSignature(
state_.transactions, {}, data::TransactionHistoryCache::walletIdentityHash(walletIdentity),
transaction_history_cache_.keyId(), shielded_history_scan_heights_, loaded.tipHeight, loaded.tipHash);
history_store_.have = true;
history_store_.lastSubmit = std::chrono::steady_clock::now();
history_store_pending_ = false;
markTransactionsDirty();
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
void App::storeTransactionHistoryCacheIfAvailable()
void App::storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode mode)
{
if (state_.transactions.empty()) return;
// Re-armed below only while a content change is still owed, so the pump can never spin on it.
history_store_pending_ = false;
// Lite never reads this cache (every load path is full-node RPC); the screenshot sweep's demo rows must
// never reach it.
if (isLiteBuild() || capture_mode_ || state_.transactions.empty()) return;
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
try {
std::string walletIdentity = transactionHistoryCacheWalletIdentity();
if (walletIdentity.empty()) return;
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
pruneShieldedHistoryScanProgress();
if (!ensureTransactionHistoryCacheUnlockedFor(walletIdentity)) return;
pruneShieldedHistoryScanProgress();
std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
std::vector<TransactionInfo> cacheTransactions;
cacheTransactions.reserve(state_.transactions.size());
for (const auto& transaction : state_.transactions) {
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
cacheTransactions.push_back(transaction);
// What would be persisted: every row except the pending sends' provisional ones.
const std::unordered_set<std::string> pendingOpids(pending_opids_.begin(), pending_opids_.end());
const auto sig = data::historySnapshotSignature(
state_.transactions, pendingOpids,
data::TransactionHistoryCache::walletIdentityHash(walletIdentity),
transaction_history_cache_.keyId(), shielded_history_scan_heights_,
state_.sync.blocks, state_.sync.best_blockhash);
if (sig.rows == 0) return;
const auto now = std::chrono::steady_clock::now();
data::HistoryStoreInput in;
in.mode = mode;
in.haveStored = history_store_.have;
in.stored = history_store_.sig;
in.current = sig;
in.sinceLastSubmitSec = history_store_.lastSubmit == std::chrono::steady_clock::time_point{}
? 1e9
: std::chrono::duration<double>(now - history_store_.lastSubmit).count();
switch (data::decideHistoryStore(in)) {
case data::HistoryStoreAction::Skip:
return;
case data::HistoryStoreAction::Defer:
history_store_pending_ = true; // pumpTransactionHistoryStore() releases it from update()
return;
case data::HistoryStoreAction::Store:
break;
}
// A self-contained job: the writer thread never touches App state.
auto job = std::make_shared<data::TransactionHistoryCache::SnapshotJob>();
if (!transaction_history_cache_.captureKey(walletIdentity, job->key)) return;
job->walletIdentity = std::move(walletIdentity);
job->tipHeight = state_.sync.blocks;
job->tipHash = state_.sync.best_blockhash;
job->updatedAt = state_.last_tx_update != 0
? static_cast<std::time_t>(state_.last_tx_update)
: std::time(nullptr);
job->shieldedScanHeights = shielded_history_scan_heights_;
job->transactions.reserve(sig.rows);
for (const auto& transaction : state_.transactions) {
if (pendingOpids.find(transaction.txid) != pendingOpids.end()) continue;
job->transactions.push_back(transaction);
}
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)); });
// Only once the job is queued: a throwing submit must not leave the session believing it is on disk.
history_store_.have = true;
history_store_.sig = sig;
history_store_.lastSubmit = now;
} catch (const std::exception& e) {
// Never let a cache write break its caller (beginShutdown, onDisconnected, a refresh callback).
DEBUG_LOGF("[History] snapshot store skipped: %s\n", e.what());
} catch (...) {
DEBUG_LOGF("[History] snapshot store skipped\n");
}
if (cacheTransactions.empty()) return;
}
std::time_t updatedAt = state_.last_tx_update != 0
? static_cast<std::time_t>(state_.last_tx_update)
: std::time(nullptr);
transaction_history_cache_.replace(walletIdentity,
state_.sync.blocks,
state_.sync.best_blockhash,
cacheTransactions,
updatedAt,
shielded_history_scan_heights_);
void App::pumpTransactionHistoryStore()
{
// A failed write (disk full, locked DB) is not on disk: the next store rewrites everything, paced like
// any content change.
if (history_writer_.takeFailure("tx-history")) {
history_store_.have = false;
history_store_pending_ = true;
}
if (!history_store_pending_) return;
if (history_store_.lastSubmit != std::chrono::steady_clock::time_point{} &&
std::chrono::duration<double>(std::chrono::steady_clock::now() - history_store_.lastSubmit).count()
< data::kHistoryMinStoreIntervalSec)
return; // O(1) until the deadline
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Coalesce);
}
void App::refreshData()
@@ -2448,8 +2525,10 @@ void App::refreshAddressData(services::ScanTicket ticket)
last_tx_block_height_ = -1;
network_refresh_.markDue(services::NetworkRefreshService::Timer::Transactions);
}
// An address refresh changes what the snapshot holds only through the wallet identity: re-key it
// under the new one at once when the set changed (the rows are the history refresh's business).
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
else storeTransactionHistoryCacheIfAvailable();
else if (addressSetChanged) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
// Hints compare the notes with the history on screen: only once it's there (the cache loaded
// just above, or a pass is done). Before that every note looks missing, and the first pass scans
// every address anyway.
@@ -3807,7 +3886,7 @@ void App::setMiningAddress(const std::string& addr, bool mining)
changed = true;
}
}
if (changed) storeTransactionHistoryCacheIfAvailable();
if (changed) storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); // user relabel: persist now
}
void App::invalidateAddressValidationCache()
+3 -1
View File
@@ -599,7 +599,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
unlockTransactionHistoryCacheWithPassphrase(newPass);
storeTransactionHistoryCacheIfAvailable();
// New key: the old ciphertext is unreadable with it. Write now unless the unlock just did
// (keyId() is part of the content signature, so Now skips only if the same key is submitted).
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
} else {
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
@@ -0,0 +1,130 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "transaction_history_store_policy.h"
#include <cstring>
namespace dragonx {
namespace data {
namespace {
constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL;
constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd
static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits");
// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent
// on purpose: the value never leaves the process.
inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n)
{
const auto* b = static_cast<const unsigned char*>(data);
while (n >= 8) {
std::uint64_t w;
std::memcpy(&w, b, 8);
h = (h ^ w) * kMul;
h ^= h >> 32;
b += 8;
n -= 8;
}
if (n) {
std::uint64_t w = 0;
std::memcpy(&w, b, n);
h = (h ^ w ^ (static_cast<std::uint64_t>(n) << 56)) * kMul;
h ^= h >> 32;
}
}
inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); }
inline void mixStr(std::uint64_t& h, const std::string& s)
{
mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc")
mixBytes(h, s.data(), s.size());
}
inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer
{
x += 0x9e3779b97f4a7c15ULL;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85
// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache
// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470).
inline std::uint64_t confirmationBucket(int c)
{
return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4;
}
} // namespace
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
const std::unordered_set<std::string>& skipTxids,
const std::string& walletHash,
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
const std::string& tipHash)
{
HistorySnapshotSig sig;
std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count
std::uint64_t exactSum = 0;
for (const auto& tx : transactions) {
if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue;
std::uint64_t h = kOffset;
mixStr(h, tx.txid);
mixStr(h, tx.type);
std::uint64_t amountBits = 0;
std::memcpy(&amountBits, &tx.amount, sizeof(amountBits));
mixU64(h, amountBits);
mixU64(h, static_cast<std::uint64_t>(tx.timestamp));
mixStr(h, tx.address);
mixStr(h, tx.from_address);
mixStr(h, tx.memo);
std::uint64_t e = h;
mixU64(h, confirmationBucket(tx.confirmations));
mixU64(e, static_cast<std::uint64_t>(static_cast<std::int64_t>(tx.confirmations)));
rowSum += finalize(h);
exactSum += finalize(e);
++sig.rows;
}
std::uint64_t c = kOffset;
mixU64(c, rowSum);
mixU64(c, sig.rows);
mixStr(c, walletHash);
mixU64(c, keyId);
sig.content = finalize(c);
std::uint64_t heightSum = 0;
for (const auto& [address, height] : shieldedScanHeights) {
if (address.empty() || height < 0) continue; // exactly what replace() persists
std::uint64_t h = kOffset;
mixStr(h, address);
mixU64(h, static_cast<std::uint64_t>(height));
heightSum += finalize(h);
}
std::uint64_t s = kOffset;
mixU64(s, heightSum);
mixU64(s, exactSum);
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(tipHeight)));
mixStr(s, tipHash);
sig.slow = finalize(s);
return sig;
}
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in)
{
const bool contentDirty = !in.haveStored || in.stored.content != in.current.content;
const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow;
if (in.mode == HistoryStoreMode::Now)
return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip;
if (contentDirty)
return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store
: HistoryStoreAction::Defer;
if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store;
return HistoryStoreAction::Skip;
}
} // namespace data
} // namespace dragonx
@@ -0,0 +1,64 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure decision logic for persisting the tx-history snapshot: what counts as "changed", and when a write is
// due. No I/O, no clock, no sodium: unit-tested directly.
#pragma once
#include "wallet_state.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
namespace data {
// 64-bit fingerprints of what a snapshot would contain.
struct HistorySnapshotSig {
std::uint64_t content = 0; // rows (confirmations by UI bucket) + wallet hash + key id
std::uint64_t slow = 0; // shielded scan heights + raw confirmations + tip: drift every block
std::size_t rows = 0; // rows that would be persisted (pending sends excluded)
};
// Order-independent over rows. `content` covers the persisted row fields (txid, type, amount bit pattern,
// timestamp, address, from_address, memo) with confirmations bucketed at the thresholds the UI and the refresh
// read off a stored row (< 0 conflicted, 0 pending, 1-9 counted, >= 10 confirmed, >= 100 mature), plus the
// wallet hash and `keyId` (a passphrase change must rewrite: the old ciphertext is unreadable with the new key).
// `slow` covers what changes every block: the per-address scan heights (entries replace() would persist), each
// row's raw confirmations, and the tip.
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
const std::unordered_set<std::string>& skipTxids,
const std::string& walletHash,
std::uint64_t keyId,
const std::unordered_map<std::string, int>& shieldedScanHeights,
int tipHeight,
const std::string& tipHash);
enum class HistoryStoreMode {
Coalesce, // routine refresh: content changes at most every kHistoryMinStoreIntervalSec, slow drift every
// kHistorySlowStoreIntervalSec
Now // user action / disconnect / lock / exit: store now if anything differs from the last submit
};
enum class HistoryStoreAction { Skip, Defer, Store };
constexpr double kHistoryMinStoreIntervalSec = 20.0;
constexpr double kHistorySlowStoreIntervalSec = 600.0;
struct HistoryStoreInput {
HistoryStoreMode mode = HistoryStoreMode::Coalesce;
bool haveStored = false; // a snapshot of this session (submitted, or the loaded one) is on disk
HistorySnapshotSig stored; // its signature
HistorySnapshotSig current;
double sinceLastSubmitSec = 1e9; // 1e9 = never submitted this session
};
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& input);
} // namespace data
} // namespace dragonx
+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();