Skip to content

perf(ui): incremental paged home timeline — reloads stop being O(wallet) - #974

Merged
QuantumExplorer merged 1 commit into
feat/marketplace-browsefrom
claude/xenodochial-bouman-06f8aa
Aug 10, 2026
Merged

perf(ui): incremental paged home timeline — reloads stop being O(wallet)#974
QuantumExplorer merged 1 commit into
feat/marketplace-browsefrom
claude/xenodochial-bouman-06f8aa

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

After the scoped wallet-source fetches (93c370a), HomeViewModel's full reload was the last O(wallet) hot path: every debounced sync/save tick called fetchAll()fetchAndWrap, re-materializing and re-wrapping the entire history — measured 4.5–6.1s idle (8.6s under recovery-sync load) per tick on a 7,007-tx CoinJoin-heavy wallet. During a recovery sync the reload ticks burned seconds of CPU each, indefinitely.

What was done?

The feed now renders from a windowed row cache instead of the whole history:

  • Day-completed first pagefetchTimelineWindow loads the newest ~100 rows via a firstSeen-index keyset scan and always finishes its boundary calendar day, so a loaded day is never partially represented and the per-day CoinJoin mixing groups stay exact without a full-history pass. firstSeen doubles as the day key: the persister adopts the block timestamp once a tx is mined, so it equals the display date for settled history.
  • Grow on scroll — a stamp-keyed tail sentinel in HomeView pages the next day-completed slice in (fetchOlderTimelinePage), auto-continuing while visible so a filter that matches nothing keeps paging.
  • Delta reconcile on ticksfetchTimelineDelta fetches only rows with lastUpdated past the reconcile stamp AND firstSeen inside the window, SQL-side. Restored history landing below the window costs nothing. Saves that delete feed rows (UnconfirmedTransactionRemover) and fiat-currency changes flag a window-sized refetch instead — deltas can't observe deletions, and cached wrappers hold currency-specific strings.
  • Aggregations decoupled from row wrapping — the Rewards/Masternode filter gates become fetchLimit=1 existence probes on transactionTypeKind (coinbase=8, provider kinds 2–5); the cross-day "CoinJoin Withdrawals" group totals every tagged sweep via point lookups (CoinJoinWithdrawalStore.allTxids) and renders once its day is paged in; shielded/platform items clamp to the loaded day range; the "Date unknown" band renders only with full history loaded.
  • Recovery growth cap — while the window still covers the whole (small) history mid-restore, deltas grow it with the wallet; past 400 rows it re-tightens to ~one page (never after the user explicitly paged).
  • TransactionSource grows the timeline API with fixture defaults (whole allTransactions as one complete window), so the onboarding stub and previews work unchanged.
  • Fixed in passing: UInt64 bounds in a SwiftData #Predicate round-trip through SQLite's signed Int64 — a .max "no upper bound" sentinel compared as −1 and silently matched nothing. scopedRows clamps bounds to Int64.max.

How Has This Been Tested?

  • Clean canonical dashpay simulator build (ARCHS=arm64).
  • In-process measurement on the QA-iPhone16 sim against the synthetic 7,007-tx / 12,223-TXO mainnet store (lldb, same recipe as the 93c370a baseline): first page 558ms cold / 173ms warm (100 rows), older page 310ms (104 rows), no-change delta 1ms, realistic 10-min delta 4ms (picked up 2 rows the live persister wrote mid-sync), filter-gate probes 12–30ms — vs 4.5–6.1s per full pass. Gate probes returned the ground-truth answers (hasRewards/hasMasternodes both true on the synthetic store).
  • Unit-test target remains broken repo-wide (pre-existing); no runnable tests exist for this area.

Reviewer notes: the delta stamp must never advance from older-page fetches (a paged-in row's lastUpdated can postdate window updates the next delta still has to pick up); receipt matching for shielded items sees the loaded window — the only miss is a receipt up to 1h before an item across the window's bottom midnight, which self-resolves when that day pages in.

Breaking Changes

None — UI behavior change only: history below the loaded window renders progressively as the user scrolls instead of all at once.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

…llet)

HomeViewModel's reload was the last O(wallet) hot path after the scoped-
fetch work: every debounced sync/save tick re-materialized and re-wrapped
the entire history (fetchAll -> walletTxRollup + full IN-fetch), measured
4.5-8.6s per tick on a 7,007-tx CoinJoin-heavy wallet. During a recovery
sync that burned seconds of CPU per second, indefinitely.

The feed now renders from a windowed row cache instead of the whole
history:

- First paint loads the newest ~100 rows via a firstSeen-index keyset
  page (fetchTimelineWindow). Pages are DAY-COMPLETED — the fetch always
  finishes its boundary calendar day, so a loaded day is never partially
  represented and the per-day CoinJoin mixing groups stay exact without
  a full-history pass. firstSeen doubles as the day key: the persister
  adopts the block timestamp once a tx is mined, so it matches the
  display date for settled history.
- Scrolling to the feed's tail pages the next day-completed slice in
  (fetchOlderTimelinePage + a stamp-keyed sentinel row that re-fires
  while visible, so filters that match nothing keep paging).
- Save/balance/sync ticks reconcile via fetchTimelineDelta: rows with
  lastUpdated past the reconcile stamp AND firstSeen inside the window,
  SQL-side. Restored history landing below the window costs nothing.
  Saves that DELETE feed rows (UnconfirmedTransactionRemover) and fiat-
  currency changes flag a window-sized refetch instead (deltas can't see
  deletions; cached wrappers hold currency-specific strings).
- The Rewards/Masternode filter gates are answered by fetchLimit=1
  existence probes on transactionTypeKind (coinbase=8, provider kinds
  2-5) — never by scanning wrapped history. The cross-day "CoinJoin
  Withdrawals" group totals all tagged sweeps via point lookups
  (CoinJoinWithdrawalStore.allTxids) and renders once its day is paged
  in. Shielded/platform items clamp to the loaded day range; the
  "Date unknown" band renders only with full history loaded.
- A recovery-sync growth cap trims an untouched full-history window
  back to ~one page past 400 rows (never after explicit paging).
- Fixed in passing: UInt64 predicate bounds round-trip through SQLite's
  signed Int64, so a .max sentinel compared as -1 and matched nothing —
  scopedRows clamps bounds to Int64.max.

TransactionSource grows the timeline API with fixture defaults (whole
allTransactions as one complete window), so the onboarding stub and
previews keep working unchanged.

Measured on the synthetic 7,007-tx mainnet store (QA-iPhone16 sim, lldb
in-process): first page 558ms cold / 173ms warm (100 rows), older page
310ms (104 rows), no-change delta 1ms, realistic 10-min delta 4ms
(2 rows), filter-gate probes 12-30ms — vs 4.5-6.1s per full pass
(idle; 8.6s under recovery-sync load).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f67b70b-b39a-4caf-811b-7dc5de0e238c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@QuantumExplorer QuantumExplorer changed the title perf(home): incremental paged home timeline — reloads stop being O(wallet) perf(ui): incremental paged home timeline — reloads stop being O(wallet) Aug 10, 2026
@QuantumExplorer
QuantumExplorer merged commit 80fabef into feat/marketplace-browse Aug 10, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant