-
Notifications
You must be signed in to change notification settings - Fork 58
perf(swift-sdk): linear wallet-changeset rounds via per-round bulk-prefetch cache #4392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
PastaPastaPasta
wants to merge
5
commits into
v4.2-dev
Choose a base branch
from
perf/linear-wallet-persistence-rounds
base: v4.2-dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
593f48c
perf(swift-sdk): linear wallet-changeset rounds via per-round bulk-pr…
PastaPastaPasta cd00e67
fix(swift-sdk): failed bulk prefetches fall back to per-row fetches, …
PastaPastaPasta 5d0a0f1
fix(swift-sdk): keep a thrown pending-input fallback fetch non-author…
PastaPastaPasta f5f4a4b
fix(swift-sdk): reject the changeset round when a round read throws
PastaPastaPasta 86c9033
test(swift-sdk): pin the changeset round's fetch count instead of its…
PastaPastaPasta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
642 changes: 514 additions & 128 deletions
642
...ages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
84 changes: 84 additions & 0 deletions
84
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BulkFetchPredicateTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import XCTest | ||
| import SwiftData | ||
| @testable import SwiftDashSDK | ||
|
|
||
| /// Proving ground for the bulk `IN`-style fetches the wallet-changeset | ||
| /// round cache relies on (`PlatformWalletPersistenceHandler`'s | ||
| /// prefetch pass). | ||
| /// | ||
| /// SwiftData translates `[Data].contains($0.column)` into a SQL | ||
| /// `IN (?, ?, …)` — but nothing else in this package exercised that | ||
| /// form before the round cache, and the sibling `Set.contains` form | ||
| /// famously does NOT translate (it throws at predicate-compile time). | ||
| /// These tests pin the exact contract the cache builder depends on: | ||
| /// | ||
| /// 1. an `[Data]`-captured `contains` predicate round-trips BLOB keys | ||
| /// through the store, in chunks below SQLite's bind-variable limit; | ||
| /// 2. rows staged (unsaved) in the same context remain visible to the | ||
| /// bulk fetch (`includePendingChanges` default), which is what lets | ||
| /// the prefetch see rows earlier per-kind callbacks inserted in the | ||
| /// same begin/end changeset round. | ||
| @MainActor | ||
| final class BulkFetchPredicateTests: XCTestCase { | ||
|
|
||
| func testChunkedDataContainsPredicateFetchesAllSavedRows() throws { | ||
| let container = try DashModelContainer.createInMemory() | ||
| let context = ModelContext(container) | ||
|
|
||
| // More rows than one SQLite bind chunk (900) so the chunked | ||
| // fetch path is genuinely exercised. | ||
| let total = 2_000 | ||
| var outpoints: [Data] = [] | ||
| outpoints.reserveCapacity(total) | ||
| for i in 0..<total { | ||
| let tx = PersistentTransaction(txid: makeTxid(i), transactionData: Data()) | ||
| context.insert(tx) | ||
| let txo = PersistentTxo(transaction: tx, vout: 0, amount: UInt64(i), address: "addr\(i)") | ||
| context.insert(txo) | ||
| outpoints.append(txo.outpoint) | ||
| } | ||
| try context.save() | ||
|
|
||
| var fetched: [Data: PersistentTxo] = [:] | ||
| for chunk in stride(from: 0, to: outpoints.count, by: 900).map({ | ||
| Array(outpoints[$0..<min($0 + 900, outpoints.count)]) | ||
| }) { | ||
| let descriptor = FetchDescriptor<PersistentTxo>( | ||
| predicate: #Predicate { chunk.contains($0.outpoint) } | ||
| ) | ||
| for row in try context.fetch(descriptor) { | ||
| fetched[row.outpoint] = row | ||
| } | ||
| } | ||
|
|
||
| XCTAssertEqual(fetched.count, total) | ||
| for outpoint in outpoints { | ||
| XCTAssertNotNil(fetched[outpoint]) | ||
| } | ||
| // Spot-check a payload survived the BLOB round trip. | ||
| XCTAssertEqual(fetched[outpoints[1234]]?.amount, 1234) | ||
| } | ||
|
|
||
| func testDataContainsPredicateSeesUnsavedPendingRows() throws { | ||
| let container = try DashModelContainer.createInMemory() | ||
| let context = ModelContext(container) | ||
|
|
||
| // One durably saved row, one staged-only row — the bulk fetch | ||
| // must see both, exactly like a mid-round prefetch that runs | ||
| // after earlier callbacks staged inserts without saving. | ||
| let savedTx = PersistentTransaction(txid: makeTxid(1), transactionData: Data()) | ||
| context.insert(savedTx) | ||
| try context.save() | ||
|
|
||
| let pendingTx = PersistentTransaction(txid: makeTxid(2), transactionData: Data()) | ||
| context.insert(pendingTx) | ||
|
|
||
| let txids = [makeTxid(1), makeTxid(2), makeTxid(3)] | ||
| let descriptor = FetchDescriptor<PersistentTransaction>( | ||
| predicate: #Predicate { txids.contains($0.txid) } | ||
| ) | ||
| let rows = try context.fetch(descriptor) | ||
|
|
||
| XCTAssertEqual(Set(rows.map(\.txid)), [makeTxid(1), makeTxid(2)]) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FFIFixtures.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import Foundation | ||
| @testable import SwiftDashSDK | ||
|
|
||
| // Shared fixtures for suites that hand-build the C structs the | ||
| // persistence handler consumes. Both conversions below were previously | ||
| // re-declared privately in every such suite; they are pure value | ||
| // transforms with no test-local state, so one copy serves all of them. | ||
|
|
||
| /// Copy a 32-byte `Data` into the C fixed-array tuple shape the FFI | ||
| /// structs use for txids, wallet ids, and identity ids. | ||
| func tuple32(_ data: Data) -> FFIByteTuple32 { | ||
| precondition(data.count == 32) | ||
| var tuple: FFIByteTuple32 = ( | ||
| 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
| 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 | ||
| ) | ||
| withUnsafeMutableBytes(of: &tuple) { $0.copyBytes(from: data) } | ||
| return tuple | ||
| } | ||
|
|
||
| /// Deterministic 32-byte txid for index `i`: the little-endian `UInt64` | ||
| /// in the leading bytes keeps ids readable in failure output and lets a | ||
| /// test recover `i` back out of a stored key (see the outpoint decode in | ||
| /// `WalletChangesetRoundTests`). | ||
| func makeTxid(_ i: Int) -> Data { | ||
| var txid = Data(count: 32) | ||
| withUnsafeBytes(of: UInt64(i).littleEndian) { txid.replaceSubrange(0..<8, with: $0) } | ||
| return txid | ||
| } |
38 changes: 38 additions & 0 deletions
38
packages/swift-sdk/SwiftTests/SwiftDashSDKTests/FetchFaultInjector.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import Foundation | ||
| import SwiftData | ||
| @testable import SwiftDashSDK | ||
|
|
||
| /// `ModelFetching` seam double: serves every read live except the one | ||
| /// model type it is told to fault (none by default), and records the | ||
| /// reads it saw so a test can prove which fetch failed — or, with no | ||
| /// fault, count how many reads a code path issued. | ||
| final class FetchFaultInjector: ModelFetching, @unchecked Sendable { | ||
| struct ReadFault: Error {} | ||
|
|
||
| private let live = LiveModelFetcher() | ||
| private let faulted: ObjectIdentifier? | ||
| private let lock = NSLock() | ||
| private var reads: [String] = [] | ||
|
|
||
| init(faulting model: (any PersistentModel.Type)? = nil) { | ||
| faulted = model.map { ObjectIdentifier($0) } | ||
| } | ||
|
|
||
| /// Model names in the order they were read, the faulted one included. | ||
| var observedReads: [String] { | ||
| lock.lock() | ||
| defer { lock.unlock() } | ||
| return reads | ||
| } | ||
|
|
||
| func fetch<T: PersistentModel>( | ||
| _ descriptor: FetchDescriptor<T>, | ||
| in context: ModelContext | ||
| ) throws -> [T] { | ||
| lock.lock() | ||
| reads.append(String(describing: T.self)) | ||
| lock.unlock() | ||
| guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } | ||
| return try live.fetch(descriptor, in: context) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💬 Test re-implements the chunking instead of exercising
chunked(_:size:)Because
PlatformWalletPersistenceHandler.chunkedisprivate, this test hand-rolls the same stride/slice logic inline — so it validates a copy of the algorithm, not the shipped helper, and the two can drift (say, a future chunk-size or slicing change) without this pin noticing. Wideningchunkedtointernal(the suite already imports@testable) and calling it here would make the contract test bind to the real code; this PR's new FFIFixtures.swift shows the pattern of promoting shared test plumbing when a second user appears.🤖 Posted autonomously by Claude on behalf of pasta.