Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 80 additions & 15 deletions src/dashboard/transaction-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,17 @@ export function transactionHistoryReducer(
*
* The cache is keyed on the identity of `state.transactions` (not on the state
* object) because pagination and page-size changes produce a *new state* that
* reuses the *same* rows array — so paging through a loaded page is now pure
* cache hits with no filter and no sort. A new indexer payload allocates a new
* array via `normalizeTransactions`, which misses the cache and recomputes;
* a filter change is caught by the signature check. `WeakMap` keeps entries
* collectable as soon as the rows array is dropped.
* reuses the *same* rows array — so paging through a loaded page is a filter
* cache hit (`selectFilteredTransactions`/`entry.filtered`). A new indexer
* payload allocates a new array via `normalizeTransactions`, which misses the
* cache and recomputes; a filter change is caught by the signature check.
* `WeakMap` keeps entries collectable as soon as the rows array is dropped.
*
* Ranking/pagination itself (`selectVisibleTransactions`) is NOT cached: it
* runs a bounded top-k selection (see `topKByTimestampDesc` below) against
* the cached filtered rows on every call, in O(n log k) rather than the full
* O(n log n) sort this used to do — cheap enough that recomputing it per call
* is the point, not a cost to avoid.
*
* This assumes records are treated as immutable, which holds throughout this
* module: the reducer never mutates state and normalisation always allocates
Expand All @@ -469,8 +475,6 @@ export function transactionHistoryReducer(
interface SelectorCacheEntry {
signature: string;
filtered: TransactionRecord[];
/** Sorted copy, computed lazily — only `selectVisibleTransactions` needs it. */
sorted: TransactionRecord[] | null;
}

const NO_TRANSACTIONS: readonly TransactionRecord[] = Object.freeze([]);
Expand All @@ -485,7 +489,7 @@ function selectorEntry(
: NO_TRANSACTIONS;
const filters = { ...INITIAL_TRANSACTION_FILTERS, ...(state?.filters ?? {}) };
const search = asString(filters.search).trim().toLowerCase();
const signature = `${filters.status}${filters.kind}${filters.direction}${search}`;
const signature = `${filters.status} ${filters.kind} ${filters.direction} ${search}`;

const cached = selectorCache.get(transactions);
if (cached !== undefined && cached.signature === signature) return cached;
Expand All @@ -504,7 +508,7 @@ function selectorEntry(
);
});

const entry: SelectorCacheEntry = { signature, filtered, sorted: null };
const entry: SelectorCacheEntry = { signature, filtered };
selectorCache.set(transactions, entry);
return entry;
}
Expand All @@ -516,21 +520,82 @@ export function selectFilteredTransactions(
return selectorEntry(state).filtered;
}

interface RankedTransaction {
tx: TransactionRecord;
idx: number; // breaks ties the same way a stable sort would
}

function isLowerPriority(a: RankedTransaction, b: RankedTransaction): boolean {
if (a.tx.timestamp !== b.tx.timestamp) return a.tx.timestamp < b.tx.timestamp;
return a.idx > b.idx;
}

function siftDown(heap: RankedTransaction[], i: number): void {
const n = heap.length;
for (;;) {
const left = 2 * i + 1;
const right = 2 * i + 2;
let smallest = i;
if (left < n && isLowerPriority(heap[left]!, heap[smallest]!)) {
smallest = left;
}
if (right < n && isLowerPriority(heap[right]!, heap[smallest]!)) {
smallest = right;
}
if (smallest === i) return;
const tmp = heap[i]!;
heap[i] = heap[smallest]!;
heap[smallest] = tmp;
i = smallest;
}
}

/** Returns the `k` newest transactions, sorted newest-first, in O(n log k). */
function topKByTimestampDesc(transactions: TransactionRecord[], k: number): TransactionRecord[] {
if (k <= 0 || transactions.length === 0) return [];

const heap: RankedTransaction[] = [];
for (let idx = 0; idx < transactions.length; idx++) {
const candidate: RankedTransaction = { tx: transactions[idx]!, idx };
if (heap.length < k) {
heap.push(candidate);
let i = heap.length - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (isLowerPriority(heap[parent]!, heap[i]!)) break;
const tmp = heap[i]!;
heap[i] = heap[parent]!;
heap[parent] = tmp;
i = parent;
}
} else if (isLowerPriority(heap[0]!, candidate)) {
heap[0] = candidate;
siftDown(heap, 0);
}
}

return heap
.sort((a, b) => (isLowerPriority(a, b) ? 1 : isLowerPriority(b, a) ? -1 : 0))
.map((r) => r.tx);
}

/** Filtered rows, newest first, sliced to the current page. */
export function selectVisibleTransactions(
state: TransactionHistoryState | undefined,
): TransactionRecord[] {
const entry = selectorEntry(state);
const sorted =
entry.sorted ??
(entry.sorted = [...entry.filtered].sort((a, b) => b.timestamp - a.timestamp));
const filtered = selectFilteredTransactions(state);

const pageSize = Math.max(1, Math.trunc(state?.pageSize ?? DEFAULT_PAGE_SIZE));
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const page = clampPage(state?.page ?? 0, totalPages);
const start = page * pageSize;

return sorted.slice(start, start + pageSize);
const k = start + pageSize;
const top =
k < filtered.length * 0.5
? topKByTimestampDesc(filtered, k)
: [...filtered].sort((a, b) => b.timestamp - a.timestamp);
return top.slice(start, start + pageSize);
}

/** Total number of pages for the current filters (always >= 1). */
Expand Down
32 changes: 32 additions & 0 deletions src/tests/transaction-history-crash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,38 @@ describe('#136 — selectors', () => {
expect(selectVisibleTransactions(shrunk)).toHaveLength(1);
});

it('keeps equal timestamps in original order, matching a stable full sort (#375)', () => {
const tied = [
makeRaw({ id: 'first', timestamp: 5000 }),
makeRaw({ id: 'second', timestamp: 5000 }),
makeRaw({ id: 'third', timestamp: 5000 }),
makeRaw({ id: 'newest', timestamp: 9000 }),
];
const state = transactionHistoryReducer(undefined, { type: 'LOAD_SUCCESS', payload: tied });
expect(selectVisibleTransactions(state).map((t) => t.id)).toEqual([
'newest', 'first', 'second', 'third',
]);
});

it('matches a brute-force full sort across a large dataset and every page (#375)', () => {
const n = 2500;
const pageSize = 25;
const payloadLarge = Array.from({ length: n }, (_, i) =>
makeRaw({ id: `tx-${i}`, timestamp: Math.floor(i % 37) }),
);
const state = {
...transactionHistoryReducer(undefined, { type: 'LOAD_SUCCESS', payload: payloadLarge }),
pageSize,
};
const bruteForce = [...selectFilteredTransactions(state)].sort((a, b) => b.timestamp - a.timestamp);
const totalPages = selectTotalPages(state);
for (const page of [0, 1, 2, Math.floor(totalPages / 2), totalPages - 1]) {
const got = selectVisibleTransactions({ ...state, page }).map((t) => t.id);
const want = bruteForce.slice(page * pageSize, page * pageSize + pageSize).map((t) => t.id);
expect(got).toEqual(want);
}
});

it('rejects an invalid page size', () => {
for (const bad of [0, -1, NaN, 'abc', undefined, null]) {
const next = transactionHistoryReducer(loaded, {
Expand Down
Loading