Skip to content

perf(swift-sdk): linear wallet-changeset rounds via per-round bulk-prefetch cache - #4385

Closed
PastaPastaPasta wants to merge 2 commits into
dashpay:v4.2-devfrom
PastaPastaPasta:perf/linear-wallet-persistence-rounds
Closed

perf(swift-sdk): linear wallet-changeset rounds via per-round bulk-prefetch cache#4385
PastaPastaPasta wants to merge 2 commits into
dashpay:v4.2-devfrom
PastaPastaPasta:perf/linear-wallet-persistence-rounds

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 12, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Restoring a wallet with a large transaction history made the app pin a CPU core for hours and grow memory without bound until the OS killed it (observed: 59 GB footprint on a mainnet wallet whose SPV scan matches ~8,000 transactions, with only 3,884 of them ever reaching disk).

The root cause is how a persistence round applies its rows. Each Rust store() round maps to one beginChangeset → per-kind callbacks → endChangeset bracket, with a single save() at the end. During SPV catch-up one round can carry thousands of transaction records, and the apply helpers (upsertTransaction, resolveInputOutpoint, upsertUtxo, markUtxoSpent, …) issued an individual ModelContext.fetch for every row, every input, and every UTXO. SwiftData evaluates each of those fetches against all objects staged so far in the unsaved round, so the more rows a round had already staged, the more expensive every following fetch became:

  • fetch # 1 scans ~0 staged objects, fetch # 100,000 scans ~100,000 → total cost grows with the square of the round size;
  • measured: ~2.3 µs × (staged objects) per fetch — the first 1,000 upserts took 1.3 s, the eighth 1,000 took 20.9 s;
  • an 8k-record round with per-input work extrapolates to hours of pinned CPU, which is why the persistence drain stalled and the app died before finishing.

What was done?

One idea, applied consistently: fetch once per round, not once per row.

  • PlatformWalletPersistenceHandler.persistWalletChangeset now builds a WalletChangesetRoundCache before applying anything: it walks the changeset once, collects every txid / outpoint / address the round could touch, and bulk-fetches the matching PersistentTransaction / PersistentTxo / PersistentPendingInput / PersistentCoreAddress rows with chunked IN predicates (≤900 keys per chunk, under SQLite's bind-variable limit).
  • All apply helpers (upsertTransaction, resolveInputOutpoint, removePendingInputs, upsertUtxo, markUtxoSpent, markUtxoInstantLocked) look rows up in the cache dictionaries instead of fetching. Inserts and deletes update the cache in place, so later rows in the same batch observe them exactly as they previously observed staged objects through per-row fetches.
  • A key the prefetch covered but found no row for is an authoritative miss; the rare key discovered mid-round (e.g. a stale pending row's spendingTxid from a prior session) falls back to a single-row fetch.
  • persistAccountAddresses gets the same treatment — its per-address row fetch and per-address TXO-backfill fetch (a second hot loop in the same rounds during restore) are now two chunked bulk fetches.

Result: a 4,000-record round drops from minutes to under a second, and the end-to-end restore that previously died at 59 GB completes a full mainnet genesis→tip sync in ~16 minutes with a ~1.2 GB peak (header download, not persistence; ~430 MB settled).

How Has This Been Tested?

New unit tests (swift test, 354 passing):

  • BulkFetchPredicateTests — pins the two SwiftData behaviors the cache depends on: [Data].contains($0.column) translating to SQL IN with >900 keys chunked, and staged (unsaved) rows staying visible to bulk fetches.
  • WalletChangesetRoundTests — drives real WalletChangeSetFFI structs through a full begin→persist→end round: a same-round chain of spends resolves every TXO↔spender linkage and drains all pending-input rows; an input with unknown funding still writes its pending-input row (the out-of-order spend-repair mechanism); and a scaling regression test asserts a 4× larger round costs near-linearly more (fails on any quadratic regression).
  • FFIFixtures — shared test helpers (deduplicates tuple32 copies that existed in DashPayPersistenceTests).

Manual end-to-end: restored a mainnet wallet reproducing the incident workload (~8k matched transactions) in SwiftExampleApp on the iOS simulator. Full chain scan completed in ~16 minutes; all matched transactions and TXOs durably persisted; sync watermark reached the chain tip; memory sampled every 30 s never exceeded ~1.25 GB; app restart came back clean with the watermark intact.

Breaking Changes

None. No public API or schema changes; the persistence semantics (round atomicity, pending-input repair, spend gating) are unchanged — only the lookup strategy inside a round.

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 added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance

    • Improved wallet synchronization and transaction processing efficiency, especially for larger wallets.
    • Reduced repeated data lookups during wallet updates and address reconciliation.
  • Reliability

    • Improved handling of pending inputs, transaction links, unspent outputs, and wallet changeset updates.
    • Ensured unknown funding inputs remain available for later reconciliation.
  • Testing

    • Added coverage for bulk data retrieval, wallet changeset processing, pending-input cleanup, and performance scaling.

…efetch cache

A single persister store() round can carry thousands of transaction records (an SPV catch-up folds many blocks into one round), and the apply helpers issued an individual ModelContext.fetch per row, per input, and per UTXO. Each fetch re-evaluates its predicate against every object staged in the open begin/end changeset bracket, so round cost grew quadratically - hours of pinned CPU for an 8k-record round on a large wallet, stalling the persistence drain behind the incident where a ~900k-txcount wallet reached 59 GB.

persistWalletChangeset now walks the changeset once, bulk-fetches every transaction / TXO / pending-input / core-address row the round could touch with chunked IN predicates, and the helpers hit per-round dictionaries; inserts and deletes update the cache in place so later rows in the batch observe them. persistAccountAddresses gets the same treatment for its per-address row and TXO-backfill fetches. A 4k-record round drops from minutes to under a second, verified by a scaling regression test.

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

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a7eee9a6-34f2-41b4-926c-f817ff9df533

📥 Commits

Reviewing files that changed from the base of the PR and between 59f3bc4 and 25dfd8c.

📒 Files selected for processing (1)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
📝 Walkthrough

Walkthrough

The Swift wallet persistence handler adds round-scoped bulk lookup caches for wallet changesets and account addresses. New tests cover chunked predicates, spend linkage, pending inputs, FFI fixtures, and processing scale.

Changes

Wallet persistence cache

Layer / File(s) Summary
Changeset round cache and reconciliation
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
The handler bulk-prefetches transaction, TXO, pending-input, and address rows. Changeset and UTXO reconciliation use cache lookups and update cached state for inserted or removed rows.
Account address persistence prefetch
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Account-address persistence bulk-prefetches address and TXO rows. It tracks inserted addresses and uses prefetched TXOs for relationship backfilling.
Cache and changeset regression coverage
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift, packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift
Tests validate chunked Data predicates, saved and unsaved rows, wallet spend linkage, pending-input behavior, processing scale, and shared FFI fixtures.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: 🟠 High · up to 59f3b

The new bulk-fetch path can treat failed database reads as missing records, potentially overwriting existing wallet data or skipping related TXO backfills. The scaling regression test may also fail on slower CI due to a tight timing bound. Merge should wait for the fetch-error handling to be corrected and the timing assertion to be made reliable.

Sequence Diagram(s)

sequenceDiagram
  participant Changeset as Wallet changeset
  participant Handler as PlatformWalletPersistenceHandler
  participant Cache as WalletChangesetRoundCache
  participant SwiftData as SwiftData context
  Changeset->>Handler: persistWalletChangeset
  Handler->>Cache: prefetch lookup keys
  Cache->>SwiftData: fetch rows in chunks
  Handler->>Cache: resolve transaction and UTXO relationships
  Cache-->>Handler: cached rows or misses
  Handler->>SwiftData: persist reconciled rows
Loading

Suggested reviewers: llbartekll, shumkov, quantumexplorer

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: improving wallet changeset round performance through per-round bulk-prefetch caching.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@thepastaclaw

thepastaclaw commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 2 ahead in queue (commit 25dfd8c)
Queue position: 3/8 · 2 reviews active
ETA: start ~00:52 UTC · complete ~01:08 UTC (median 16m across 30 recent reviews; 2 slots)
Queued 26m ago · Last checked: 2026-08-13 00:30 UTC

@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 875-904: Update the bulk prefetch loops around the cache
population logic to handle chunk fetch failures explicitly: when transaction,
outpoint, or address fetches fail, remove the affected keys from their
corresponding prefetched sets so cachedTransaction, cachedTxo,
cachedPendingInputs, and cachedCoreAddress perform per-row fallback; remove
outpoint keys only after both PersistentTxo and PersistentPendingInput fetches
complete. Also update the PersistentCoreAddress and PersistentTxo chunk fetch
handling near lines 3397-3410 to explicitly handle errors instead of swallowing
them, preserving existing rows and ensuring coreAddress backfill processes their
TXOs. Apply these changes in
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
at ranges 875-904 and 3397-3410.

In
`@packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift`:
- Around line 211-236: Update testRoundCostScalesNearLinearly so the ratio
assertion only runs when the 1,000-record baseline is sufficiently measurable,
or increase the baseline floor to provide a realistic budget for the
4,000-record round. Preserve the scaling check while avoiding failures caused by
an unrealistically tight sub-0.5-second absolute threshold on loaded CI runners.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 87030835-78be-497f-b59b-56f91ebf9602

📥 Commits

Reviewing files that changed from the base of the PR and between f05bf82 and 59f3bc4.

📒 Files selected for processing (5)
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/WalletChangesetRoundTests.swift

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Codex/Sol only (Phase 2 disabled)

The cache preserves the staged-row behavior on normal reads, but two in-scope suggestions remain: bulk-fetch failures must not become authoritative misses, and input funding transaction IDs should not widen transaction prefetches when only their outpoints are consumed. No blocking issue was verified.
Source: reviewer backend model gpt-5.6-sol; final verifier backend model gpt-5.6-sol; CodeRabbit inline findings independently verified. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:875-904: Do not turn failed bulk fetches into authoritative cache misses
  Each bulk fetch uses `try?` and substitutes an empty array, while the chunk's keys remain in the corresponding `prefetched*` set. A fetch failure is therefore indistinguishable from a successful query that found no rows: `cachedTransaction`, `cachedTxo`, `cachedPendingInputs`, and `cachedCoreAddress` suppress their fallback lookups and can insert duplicates, leave existing pending inputs unresolved, or omit address relationships. The address-persistence prefetch at lines 3397–3410 has the same amplification: one failed chunk is treated as hundreds of missing addresses or TXOs. Handle each error explicitly by either failing the persistence round or removing the affected keys from authoritative coverage so row-wise lookups can retry; for outpoints, retain coverage only when both the TXO and pending-input queries succeeded.
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:833-834: Avoid prefetching transaction rows for every input parent
  An input's previous txid is only used to construct the outpoint passed to `cachedTxo`; none of the input-resolution helpers looks up the funding `PersistentTransaction`. Transaction rows that are actually consumed are already covered by transaction records, added UTXOs, and `spending_txid` values. Adding every input parent therefore widens the chunked transaction `IN` queries and can materialize unrelated historical transaction rows on the input-heavy restore path this PR is optimizing. Keep the outpoint prefetch but omit the transaction-prefetch insertion.

Comment on lines +875 to +904
for chunk in Self.chunked(Array(cache.prefetchedTxids)) {
let descriptor = FetchDescriptor<PersistentTransaction>(
predicate: #Predicate { chunk.contains($0.txid) }
)
for row in (try? backgroundContext.fetch(descriptor)) ?? [] {
cache.transactions[row.txid] = row
}
}
for chunk in Self.chunked(Array(cache.prefetchedOutpoints)) {
let txoDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate { chunk.contains($0.outpoint) }
)
for row in (try? backgroundContext.fetch(txoDescriptor)) ?? [] {
cache.txos[row.outpoint] = row
}
let pendingDescriptor = FetchDescriptor<PersistentPendingInput>(
predicate: #Predicate { chunk.contains($0.outpoint) }
)
for row in (try? backgroundContext.fetch(pendingDescriptor)) ?? [] {
cache.pendingInputs[row.outpoint, default: []].append(row)
}
}
for chunk in Self.chunked(Array(cache.prefetchedAddresses)) {
let descriptor = FetchDescriptor<PersistentCoreAddress>(
predicate: #Predicate { chunk.contains($0.address) }
)
for row in (try? backgroundContext.fetch(descriptor)) ?? [] {
cache.coreAddresses[row.address] = row
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Do not turn failed bulk fetches into authoritative cache misses

Each bulk fetch uses try? and substitutes an empty array, while the chunk's keys remain in the corresponding prefetched* set. A fetch failure is therefore indistinguishable from a successful query that found no rows: cachedTransaction, cachedTxo, cachedPendingInputs, and cachedCoreAddress suppress their fallback lookups and can insert duplicates, leave existing pending inputs unresolved, or omit address relationships. The address-persistence prefetch at lines 3397–3410 has the same amplification: one failed chunk is treated as hundreds of missing addresses or TXOs. Handle each error explicitly by either failing the persistence round or removing the affected keys from authoritative coverage so row-wise lookups can retry; for outpoints, retain coverage only when both the TXO and pending-input queries succeeded.

source: ['coderabbit']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 25dfd8c. A failed chunk fetch now removes its keys from the prefetched set (round cache) — or records the addresses for a single-row fallback fetch in persistAccountAddresses — so a thrown bulk fetch degrades to the pre-cache per-row behavior instead of reading as an authoritative miss.


🤖 Posted autonomously by Claude on behalf of pasta.

Comment on lines +833 to +834
let prevTxid = hashData(entry.txid)
cache.prefetchedTxids.insert(prevTxid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Avoid prefetching transaction rows for every input parent

An input's previous txid is only used to construct the outpoint passed to cachedTxo; none of the input-resolution helpers looks up the funding PersistentTransaction. Transaction rows that are actually consumed are already covered by transaction records, added UTXOs, and spending_txid values. Adding every input parent therefore widens the chunked transaction IN queries and can materialize unrelated historical transaction rows on the input-heavy restore path this PR is optimizing. Keep the outpoint prefetch but omit the transaction-prefetch insertion.

Suggested change
let prevTxid = hashData(entry.txid)
cache.prefetchedTxids.insert(prevTxid)
let prevTxid = hashData(entry.txid)

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 25dfd8c — input prevout txids are no longer collected into the transaction prefetch. They were only needed by the CoinJoin funding-known gate, which was split out of this PR into a follow-up; that PR will reintroduce the collection alongside its consumer.


🤖 Posted autonomously by Claude on behalf of pasta.

…drop unused prevout-txid prefetch

A thrown chunk fetch previously left its keys in the prefetched sets, turning the error into an authoritative 'row does not exist' for ~900 keys at once - the upsert paths would then insert duplicates over unique columns. A failed chunk now removes its keys from the prefetched set (round cache) or records the addresses for a single-row fallback fetch (persistAccountAddresses), restoring the pre-cache behavior on error.

Also stop collecting input prevout txids into the transaction prefetch: the apply helpers look inputs up as TXOs / pending rows, never as transactions, so those keys only inflated the IN queries (hundreds of foreign parents per CoinJoin record). Addresses review feedback from coderabbitai and thepastaclaw on PR 4385.

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

Copy link
Copy Markdown
Member Author

Superseded by #4392 — same branch pushed in-repo so the full CI matrix (including the fork-gated Swift SDK job) runs. All review feedback from this PR is already incorporated there (head 25dfd8c).


🤖 Posted autonomously by Claude on behalf of pasta.

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