Skip to content

fix(swift-sdk): drain the persister's autorelease pool per row - #4414

Open
romchornyi wants to merge 1 commit into
v4.2-devfrom
fix/persister-autorelease-pool
Open

fix(swift-sdk): drain the persister's autorelease pool per row#4414
romchornyi wants to merge 1 commit into
v4.2-devfrom
fix/persister-autorelease-pool

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

The app reaches a ~16 GB physical footprint during a large wallet's initial scan and is killed before the scan finishes. The logs stay quiet the whole time, which is what made this hard to attribute.

The persister callbacks arrive on Rust-owned tokio worker threads, and such a thread has no autorelease pool — the drain is normally done by a run loop or by GCD's own worker wrapper, and serialQueue.sync can run the block straight on the calling thread. Nothing in this file — 8200 lines and 38 C callbacks — opened one.

That matters because SwiftData is Core Data underneath. Resolving a managed object hands back autoreleased Foundation objects, and -[_NSCoreManagedObjectID URIRepresentation] alone allocates an NSURL, an NSPathStore2 and two CFStrings. resolveInputOutpoint reaches that per input, per transaction, per block.

Heap dump at 3 GB, taken from the running app:

count class
7 512 707 NSURL
7 512 604 NSPathStore2
15 052 753 CFString
44 735 @autoreleasepool content pages (183 MB)

33 M live allocations, MALLOC_SMALL at 3.1 GB. malloc_history put every one of them under persistWalletChangesetCallbackapplyAccountChangesetupsertTransactionresolveInputOutpointURIRepresentation.

What was done?

Two pools, and the inner one is the fix.

A pool in onQueue alone still drains only when the whole changeset is done, and a large wallet's changesets are big enough that the interim peak was still millions of objects. That was measured, not assumed: it moved the live NSURL count from 7.5 M to 6.5 M — i.e. almost nothing. The per-row pools in applyAccountChangeset (transactions, added UTXOs, spent UTXOs, InstantSend locks) keep the peak flat regardless of batch size.

How Has This Been Tested?

Measured on the reproducing wallet, same device and same restore-from-seed scenario, before and after:

before after
live NSURL 7 512 707, climbing 1 000 – 18 000, flat
footprint 3+ GB, killed 496 MB, growth=+0.0 MB/s
scan never finished completes: 8582 filters matched, 3269 relevant blocks, 5095 transactions

xcodebuild test -scheme SwiftDashSDK — 352 tests, 0 failures, on the branch this was developed on. CI covers it here.

Breaking Changes

None. autoreleasepool wrappers around existing call sites; no behaviour, ordering or atomicity change — no save() was added or moved.

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
    • Improved memory management during large wallet data synchronization and persistence operations.
    • Reduced temporary memory usage while processing transactions and unspent outputs.
  • Bug Fixes
    • Preserved existing wallet persistence behavior while improving stability during bulk updates.

The persister callbacks arrive on Rust-owned tokio worker threads, and
such a thread has no autorelease pool: the drain is normally done by a
run loop or by GCD's own worker wrapper, and `serialQueue.sync` can run
the block straight on the calling thread. Nothing in this file — 8600
lines and 38 C callbacks — opened one.

That matters because SwiftData is Core Data underneath. Resolving a
managed object hands back autoreleased Foundation objects, and
`-[_NSCoreManagedObjectID URIRepresentation]` alone allocates an
`NSURL`, an `NSPathStore2` and two `CFString`s. `resolveInputOutpoint`
calls into that per input, per transaction, per block.

On a wallet with thousands of relevant blocks the app reached a 3 GB
footprint and was killed before its initial scan finished. A heap dump
at that point: 7,512,707 live `NSURL`s, 7,512,604 `NSPathStore2`s,
15,052,753 `CFString`s, 33M live allocations, 183 MB of autorelease pool
pages. `malloc_history` put every one of them under
`persistWalletChangesetCallback` → `upsertTransaction` →
`resolveInputOutpoint` → `URIRepresentation`.

There are two pools, and the inner one is the fix. A pool in `onQueue`
alone still drains only when the whole changeset is done, and a large
wallet's changesets are big enough that the interim peak was still
millions of objects — measured, not assumed: it moved the count from
7.5M to 6.5M. The per-row pools in `applyAccountChangeset` keep the peak
flat regardless of batch size.

After: the same wallet syncs to completion — 8582 filters matched, 3269
relevant blocks, 5095 transactions — at a 496 MB footprint with 1k–18k
live `NSURL`s and no growth.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a391bdb0-7112-4da9-9e5e-6098bdb4befc

📥 Commits

Reviewing files that changed from the base of the PR and between 6495991 and 0382551.

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

Included review availability: Your plan includes up to 3 reviews per rolling hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The persistence handler now scopes autoreleased allocations during serial queue operations and individual transaction and UTXO processing iterations in applyAccountChangeset.

Changes

Persistence memory management

Layer / File(s) Summary
Queue operation autorelease scope
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
The serial queue body now runs inside an autoreleasepool.
Batch item autorelease scope
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Transaction upserts and UTXO mutations now run inside per-item autorelease pools.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 03825

This localized change adds per-row autorelease-pool draining to reduce memory growth during large wallet scans without changing persistence behavior; no actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: quantumexplorer, llbartekll, shumkov

🚥 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 the main change: draining the Swift SDK persister's autorelease pool for each row.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/persister-autorelease-pool

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

@thepastaclaw

thepastaclaw commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 0382551)

@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 exact-head diff adds autorelease pools around serialized persistence work and each high-volume transaction/UTXO row without changing queue confinement, FFI pointer lifetimes, operation ordering, or save boundaries. No in-scope correctness, interoperability, or performance regressions were identified.
Source: reviewers codex general (gpt-5.6-sol) and codex ffi-engineer (gpt-5.6-sol); final verifier codex (gpt-5.6-sol). Orchestration only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

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), gpt-5.6-sol — ffi-engineer (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)

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.

3 participants