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:
@@ -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
|
||||
Reference in New Issue
Block a user