Skip to content

perf(home): coalesce transaction-list reloads and share the CrowdNode restore scan - #977

Closed
romchornyi wants to merge 2 commits into
developfrom
fix/home-history-perf
Closed

perf(home): coalesce transaction-list reloads and share the CrowdNode restore scan#977
romchornyi wants to merge 2 commits into
developfrom
fix/home-history-perf

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Restoring a wallet with a large history made the home screen look stuck: the sync percentage kept climbing while the transaction list still showed months-old history, and the app burned CPU throughout. Two independent causes, both in app code, both found in a diagnostic log from a mainnet restore of a ~6,600-transaction wallet.

1. The transaction-list reload queue took work faster than it finished it. Every reload trigger enqueued its own full pass on HomeViewModel's serial queue. A pass costs seconds at that history size while the trigger throttle upstream is one second, so the backlog grew for as long as the sync wrote rows. From the log: 221 passes, 6,252s of queued work inside a 17-minute session, and a request-to-render latency that climbed 6s → 33s → 122s → 219s while the transaction count moved only 1,537 → 1,854. The list was rendering history up to three and a half minutes stale, and roughly seven of every eight passes rebuilt a snapshot the next one had already superseded.

2. A CrowdNode restore pass scanned the whole history twice. tryRestoreSignUp and getApiAddressConfirmationTx scan with identical arguments, each a SwiftData fetch across the TXO join plus a full decode, paid inline on the calling thread:

20:37:12 restoring CrowdNode state
20:37:24 CrowdNode scan: fetch 6183 rows in 8536ms, decode in 4286ms
20:37:37 CrowdNode scan: fetch 6183 rows in 8114ms, decode in 4289ms
20:37:37 CrowdNode: account not found

That is ~25s per pass, and passes repeat whenever the persisted row count moves — during a restore, constantly.

What was done?

HomeViewModel now coalesces reloads instead of queueing them: at most one pass runs, at most one trailing pass is owed, and every trigger in between collapses into that trailing pass. Nothing is lost, because the trailing pass reads the store fresh — including the filter selection, so a filter change made during a pass still renders the newest selection.

TransactionObserver.withSharedScan serves identical fetchObserved calls made inside it from one fetch and decode, keyed by wallet, network and the scan's arguments; CrowdNode.restoreState wraps its pass in it. The sharing is deliberately scoped to the pass rather than cached across passes: a row's context and blockHeight change as it confirms without the row count moving, so a longer-lived memo would answer isChainAccepted from stale data.

Also corrects a comment on the confirmation scan's floor. It claimed the floor keeps the scan off the wallet's full history; it bounds firstSeen, which the SDK stamps when it first observes a row, so on a restored wallet every row is "first seen" now and the floor admits everything regardless of chain date. Recorded as TODO(crowdnode-restore-floor) rather than fixed here — bounding on blockTimestamp needs a SwiftData predicate change that should be verified against the store rather than reasoned about, and the rows it would exclude are a small share of a restored history.

Neither change alters what the list or the restore concludes, only how many times the work is done.

How Has This Been Tested?

Clean dashpay build for the simulator (xcodebuild -workspace DashWallet.xcworkspace -scheme dashpay -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' ARCHS=arm64 build) — ** BUILD SUCCEEDED **, no new warnings in the touched files.

Not yet exercised on device against a large restored wallet, which is where the numbers above came from and where the effect should be confirmed; the unit-test target is currently broken repo-wide, so there is no automated coverage to add against it.

Breaking Changes

None.

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

Summary by CodeRabbit

  • Performance Improvements

    • Reduced duplicate transaction-history scans during wallet state restoration.
    • Improved efficiency when processing repeated transaction data requests.
  • Bug Fixes

    • Prevented multiple overlapping home-screen reloads.
    • Consolidated rapid refresh requests into a single follow-up update, improving responsiveness and stability.

Every reload trigger enqueued its own full pass on the view model's
serial queue. A pass costs seconds once the wallet has a few thousand
transactions, while the trigger throttle upstream is one second, so
during a restore the queue took work faster than it finished it and the
backlog grew for as long as the sync wrote rows.

Measured on a mainnet restore of a ~6.6k-transaction wallet: 221 passes,
6252s of queued work inside a 17-minute session, and a request-to-render
latency that climbed 6s -> 33s -> 122s -> 219s while the transaction
count moved only 1537 -> 1854. The home list was rendering history up to
three and a half minutes stale, and roughly seven of every eight passes
rebuilt a snapshot the next one had already superseded.

Gate on a running pass instead: at most one runs, at most one trailing
pass is owed, and every trigger in between collapses into that trailing
pass. Nothing is lost because the trailing pass reads the store fresh —
including the filter selection, so a filter change during a pass still
renders the newest selection.
A restore pass scans the persisted history twice with identical
arguments — tryRestoreSignUp, then getApiAddressConfirmationTx. Both
walk the same rows to answer questions about the same wallet, and each
walk is a SwiftData fetch across the TXO join plus a full decode, paid
inline on the calling thread.

On a mainnet wallet with ~6.6k transactions each walk took ~12.4s
(8.1s fetch, 4.3s decode), so one pass spent ~25s scanning, and the
passes repeat whenever the persisted row count moves — which, during a
restore, is constantly:

    20:37:12 restoring CrowdNode state
    20:37:24 CrowdNode scan: fetch 6183 rows in 8536ms, decode in 4286ms
    20:37:37 CrowdNode scan: fetch 6183 rows in 8114ms, decode in 4289ms
    20:37:37 CrowdNode: account not found

withSharedScan serves identical fetchObserved calls made inside it from
one fetch and decode, keyed by wallet, network and the scan's arguments.
It is deliberately scoped to the pass rather than cached across passes:
a row's context and blockHeight change as it confirms without the row
count moving, so a longer-lived memo would answer isChainAccepted from
stale data.

Also corrects the claim on the confirmation scan's floor. It bounds
firstSeen, which the SDK stamps when it first observes a row, so on a
restored wallet every row is "first seen" now and the floor admits the
whole history regardless of chain date — recorded as a TODO rather than
fixed here, since bounding on blockTimestamp needs a SwiftData predicate
change that has to be verified against the store.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds scoped transaction-scan reuse to CrowdNode restoration and coalesces concurrent HomeViewModel reload requests into one active pass plus one trailing pass.

Changes

CrowdNode restore scan sharing

Layer / File(s) Summary
Shared transaction scan infrastructure
DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift
TransactionObserver caches matching scans during nested withSharedScan scopes and synchronizes cache access.
CrowdNode restoration integration
DashWallet/Sources/Models/CrowdNode/CrowdNode.swift
restoreState() uses the shared-scan scope. The API confirmation comment documents the current firstSeen date-floor limitation.

Home reload coordination

Layer / File(s) Summary
Reload request coalescing
DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
HomeViewModel tracks active and deferred reloads, then starts one trailing reload after the active pass publishes results.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: quantumexplorer, llbartekll

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes both primary changes: coalescing home reloads and sharing the CrowdNode restore scan.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/home-history-perf
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/home-history-perf

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift`:
- Around line 168-197: Replace the process-global
sharedScanDepth/sharedScanResults state with an invocation-local scope
propagated through withSharedScan, so nested calls reuse the same results while
concurrent restore passes remain isolated. Update the fetchObserved lookup path
to access the current scope, and revise the surrounding comments to describe the
implemented scope and behavior accurately.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ad46132c-8066-4ce6-955e-8586e2354729

📥 Commits

Reviewing files that changed from the base of the PR and between 9dceb85 and cd7abaa.

📒 Files selected for processing (3)
  • DashWallet/Sources/Models/CrowdNode/CrowdNode.swift
  • DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift
  • DashWallet/Sources/UI/Home/Views/HomeViewModel.swift

Comment on lines +168 to +197
private static let sharedScanLock = NSLock()
private static var sharedScanDepth = 0
private static var sharedScanResults: [SharedScanKey: [ObservedTransaction]] = [:]

/// Serves identical `fetchObserved` calls made inside `body` from a single
/// fetch + decode.
///
/// A CrowdNode restore asks the same question twice — `tryRestoreSignUp`
/// and `getApiAddressConfirmationTx` scan with identical arguments — and
/// on a wallet with thousands of transactions each pass cost ~12s of fetch
/// and decode, paid inline by the caller's thread.
///
/// Scoped to the call rather than cached across passes on purpose: a row's
/// `context` / `blockHeight` change as it confirms without the row count
/// moving, so a longer-lived memo would answer `isChainAccepted` from
/// stale data.
static func withSharedScan<T>(_ body: () throws -> T) rethrows -> T {
sharedScanLock.lock()
sharedScanDepth += 1
sharedScanLock.unlock()
defer {
sharedScanLock.lock()
sharedScanDepth -= 1
if sharedScanDepth == 0 {
sharedScanResults.removeAll()
}
sharedScanLock.unlock()
}
return try body()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Scope shared scan results to one withSharedScan invocation.

sharedScanDepth and sharedScanResults are process-global. If two restore passes overlap, the second pass can reuse the first pass's result for the same key. A row can change between the passes, but the second pass then evaluates stale confirmation data.

Use an invocation-local scan scope. Nested calls in the same restore pass must share that scope. Concurrent restore passes must use separate scopes.

As per coding guidelines, comments must describe behavior that the code actually implements.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DashWallet/Sources/Models/CrowdNode/Services/TransactionObserver.swift`
around lines 168 - 197, Replace the process-global
sharedScanDepth/sharedScanResults state with an invocation-local scope
propagated through withSharedScan, so nested calls reuse the same results while
concurrent restore passes remain isolated. Update the fetchObserved lookup path
to access the current scope, and revise the surrounding comments to describe the
implemented scope and behavior accurately.

Source: Coding guidelines

@romchornyi

Copy link
Copy Markdown
Contributor Author

Closing as duplicate.

Both commits here are already covered on develop or by work in flight:

One correction to this PR's description, for anyone reading it later: it claimed the 2022 floor on the confirmation scan does not bound anything because firstSeen is stamped at observation time. That is wrong. On the wallet the numbers came from, firstSeen matches blockTimestamp on all 6652 rows, and the floor selects exactly the 6183 post-2022 rows — so the 6183-row figure in the logs is already the floored set, not the whole history. The TODO(crowdnode-restore-floor) this branch added is therefore invalid and is not worth carrying over.

The measurements from the restore that prompted this are still worth keeping as evidence for #976: 221 full reload passes, 6252s of queued work inside a 17-minute session, and request-to-render latency climbing 6s -> 33s -> 122s -> 219s while the transaction count moved only 1537 -> 1854.

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.

2 participants