perf(history): group transaction rows by txid once per change
The History tab built a txid-to-indices map twice when its inputs changed, once for the summary-card totals and once for the display list, each a full pass over every transaction with string-keyed hashing. - The rows are now grouped by txid a single time whenever the transaction list changes, into an index-only structure (first/next chains with no string copies), and both the summary pass and the display pass reuse it. A sort-combo change re-sorts without regrouping. Adds a pure, unit-testable groupByTxid helper in transactions_tab_helpers.h. Tests: the chains match a reference map over a random id stream, and the empty and single-row cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
#include "transactions_tab.h"
|
||||
#include "transaction_details_dialog.h"
|
||||
#include "export_transactions_dialog.h"
|
||||
#include "transactions_tab_helpers.h"
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
@@ -194,6 +195,15 @@ void RenderTransactionsTab(App* app)
|
||||
// per-frame O(N) FNV pass over every field of every transaction.
|
||||
const std::uint64_t contentKey = state.tx_revision;
|
||||
|
||||
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
||||
// (a sort-combo change re-sorts without regrouping).
|
||||
static history::TxidGroups s_groups;
|
||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
||||
if (s_groups_key != contentKey) {
|
||||
s_groups = history::groupByTxid(state.transactions);
|
||||
s_groups_key = contentKey;
|
||||
}
|
||||
|
||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||
// numbers that only change when the tx list changes, so memoize them behind contentKey and
|
||||
// recompute only when it moves. File-scope-style function statics persist across wallet
|
||||
@@ -212,18 +222,16 @@ void RenderTransactionsTab(App* app)
|
||||
// list below — not income or spending. Counting both legs double-counts the amount
|
||||
// into BOTH the Sent and Received totals, so the cards disagree with the list.
|
||||
// Mirror the list's pairing logic and exclude both legs from the totals.
|
||||
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++)
|
||||
summaryTxidMap[state.transactions[i].txid].push_back(i);
|
||||
const history::TxidGroups& groups = s_groups;
|
||||
std::vector<bool> isShieldLeg(state.transactions.size(), false);
|
||||
for (const auto& kv : summaryTxidMap) {
|
||||
if (kv.second.size() < 2) continue;
|
||||
for (size_t head = 0; head < state.transactions.size(); head++) {
|
||||
if (groups.first[head] != (int)head || groups.next[head] < 0) continue; // once per txid shared by 2+ rows
|
||||
int send_i = -1, recv_i = -1;
|
||||
for (size_t si : kv.second) {
|
||||
for (int si = (int)head; si >= 0; si = groups.next[si]) {
|
||||
const auto& stx = state.transactions[si];
|
||||
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
||||
if (stx.type == "send" && send_i < 0) send_i = si;
|
||||
else if (stx.type == "receive" && recv_i < 0 &&
|
||||
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
|
||||
!stx.address.empty() && stx.address[0] == 'z') recv_i = si;
|
||||
}
|
||||
if (send_i >= 0 && recv_i >= 0) {
|
||||
isShieldLeg[send_i] = true;
|
||||
@@ -389,11 +397,8 @@ void RenderTransactionsTab(App* app)
|
||||
++s_display_gen;
|
||||
s_display_cache.clear();
|
||||
std::vector<DisplayTx>& display_txns = s_display_cache;
|
||||
// Map txid -> indices in state.transactions
|
||||
std::unordered_map<std::string, std::vector<size_t>> txid_map;
|
||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||
txid_map[state.transactions[i].txid].push_back(i);
|
||||
}
|
||||
display_txns.reserve(state.transactions.size());
|
||||
const history::TxidGroups& groups = s_groups; // built above for this tx_revision
|
||||
|
||||
std::vector<bool> consumed(state.transactions.size(), false);
|
||||
|
||||
@@ -403,14 +408,13 @@ void RenderTransactionsTab(App* app)
|
||||
|
||||
// Try to find an autoshield pair (same txid, send+receive to z-addr)
|
||||
bool merged = false;
|
||||
const auto& siblings = txid_map[tx.txid];
|
||||
if (siblings.size() >= 2) {
|
||||
if (groups.hasSiblings(i)) {
|
||||
int send_i = -1, recv_i = -1;
|
||||
for (size_t si : siblings) {
|
||||
for (int si = groups.first[i]; si >= 0; si = groups.next[si]) {
|
||||
if (consumed[si]) continue;
|
||||
const auto& stx = state.transactions[si];
|
||||
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
||||
else if (stx.type == "receive" && recv_i < 0) recv_i = (int)si;
|
||||
if (stx.type == "send" && send_i < 0) send_i = si;
|
||||
else if (stx.type == "receive" && recv_i < 0) recv_i = si;
|
||||
}
|
||||
if (send_i >= 0 && recv_i >= 0) {
|
||||
const auto& stx = state.transactions[send_i];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
#pragma once
|
||||
// Pure helpers behind the History tab (no ImGui), so they can be unit-tested.
|
||||
#include "../../data/wallet_state.h"
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace history {
|
||||
|
||||
// Rows of one txid chained in ascending index order. first[i] = lowest index sharing tx i's txid;
|
||||
// next[i] = next higher index with the same txid, or -1. Holds indices only (no string copies, no
|
||||
// pointers into the list), so it stays valid for as long as the list is unchanged (same tx_revision).
|
||||
struct TxidGroups {
|
||||
std::vector<int> first;
|
||||
std::vector<int> next;
|
||||
bool hasSiblings(std::size_t i) const { return next[static_cast<std::size_t>(first[i])] >= 0; }
|
||||
};
|
||||
|
||||
inline TxidGroups groupByTxid(const std::vector<TransactionInfo>& txs)
|
||||
{
|
||||
TxidGroups g;
|
||||
const std::size_t n = txs.size();
|
||||
g.first.assign(n, -1);
|
||||
g.next.assign(n, -1);
|
||||
std::unordered_map<std::string_view, int> last; // txid (view into txs) -> last index seen
|
||||
last.reserve(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
auto ins = last.try_emplace(std::string_view(txs[i].txid), static_cast<int>(i));
|
||||
if (ins.second) {
|
||||
g.first[i] = static_cast<int>(i);
|
||||
} else {
|
||||
g.first[i] = g.first[static_cast<std::size_t>(ins.first->second)];
|
||||
g.next[static_cast<std::size_t>(ins.first->second)] = static_cast<int>(i);
|
||||
ins.first->second = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace history
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -63,6 +63,7 @@
|
||||
#include "ui/windows/console_tab_helpers.h"
|
||||
#include "ui/windows/console_text_layout.h"
|
||||
#include "ui/windows/mining_benchmark.h"
|
||||
#include "ui/windows/transactions_tab_helpers.h"
|
||||
#include "ui/windows/mining_pool_panel.h"
|
||||
#include "ui/windows/mining_tab_helpers.h"
|
||||
#include "ui/material/scroll_edge_fade.h"
|
||||
@@ -10527,6 +10528,48 @@ void testSameTransactionList()
|
||||
}
|
||||
}
|
||||
|
||||
void testHistoryTxidGroups()
|
||||
{
|
||||
using dragonx::ui::history::groupByTxid;
|
||||
auto mk = [](const std::vector<std::string>& ids) {
|
||||
std::vector<dragonx::TransactionInfo> v;
|
||||
for (const auto& id : ids) { dragonx::TransactionInfo t; t.txid = id; v.push_back(t); }
|
||||
return v;
|
||||
};
|
||||
{
|
||||
auto g = groupByTxid(mk({"A", "B", "A", "C", "B", "A"}));
|
||||
EXPECT_TRUE((g.first == std::vector<int>{0, 1, 0, 3, 1, 0}));
|
||||
EXPECT_TRUE((g.next == std::vector<int>{2, 4, 5, -1, -1, -1}));
|
||||
EXPECT_TRUE(g.hasSiblings(0) && g.hasSiblings(1) && g.hasSiblings(2) && g.hasSiblings(5));
|
||||
EXPECT_TRUE(!g.hasSiblings(3));
|
||||
}
|
||||
{ // property check against a reference map
|
||||
unsigned seed = 12345;
|
||||
std::vector<std::string> ids;
|
||||
for (int i = 0; i < 500; ++i) { seed = seed * 1103515245u + 12345u; ids.push_back("t" + std::to_string((seed >> 16) % 40)); }
|
||||
auto txs = mk(ids);
|
||||
auto g = groupByTxid(txs);
|
||||
std::unordered_map<std::string, std::vector<size_t>> ref;
|
||||
for (size_t i = 0; i < txs.size(); ++i) ref[txs[i].txid].push_back(i);
|
||||
bool ok = true;
|
||||
for (const auto& kv : ref) {
|
||||
int cur = (int)kv.second[0];
|
||||
for (size_t k = 0; k < kv.second.size(); ++k) {
|
||||
if (cur != (int)kv.second[k] || g.first[kv.second[k]] != (int)kv.second[0]) { ok = false; break; }
|
||||
cur = g.next[cur];
|
||||
}
|
||||
if (cur != -1) ok = false;
|
||||
}
|
||||
EXPECT_TRUE(ok);
|
||||
}
|
||||
{
|
||||
auto g = groupByTxid(mk({}));
|
||||
EXPECT_TRUE(g.first.empty() && g.next.empty());
|
||||
auto s = groupByTxid(mk({"X"}));
|
||||
EXPECT_TRUE(s.first.size() == 1 && s.first[0] == 0 && s.next[0] == -1 && !s.hasSiblings(0));
|
||||
}
|
||||
}
|
||||
|
||||
// tx_revision starts at >=1 (a UI cache's 0 sentinel never matches), each helper adds exactly 1, the helpers
|
||||
// never touch last_tx_update, and clear() bumps without resetting, so no earlier revision ever recurs.
|
||||
void testWalletStateTxRevision()
|
||||
@@ -18016,6 +18059,7 @@ int main()
|
||||
testLiteRefreshModelAppliesToWalletState();
|
||||
testLiteTransactionsAreNewestFirst();
|
||||
testSameTransactionList();
|
||||
testHistoryTxidGroups();
|
||||
testWalletStateTxRevision();
|
||||
testTransactionRefreshRevision();
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
|
||||
Reference in New Issue
Block a user