-
Notifications
You must be signed in to change notification settings - Fork 24
perf(ui): collapse queued timeline reconciles, and measure what is left #990
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
base: develop
Are you sure you want to change the base?
Changes from 5 commits
55b2572
48a1a8f
4e62aaa
00dbee7
a7670c3
9f6c276
5e84693
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
|
|
||
| import Combine | ||
| import Foundation | ||
| import OSLog | ||
| import SwiftDashSDK | ||
|
|
||
| private let kMaxProgressDelta = 0.1 // 10% | ||
|
|
@@ -178,7 +179,14 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { | |
| private var lastPeakDate: Date? | ||
| private var cancellables = Set<AnyCancellable>() | ||
|
|
||
| private var observers: [SyncingActivityMonitorObserver] = [] | ||
| /// Weak, because this is a singleton that outlives every observer and a | ||
| /// strong array here turns any missed `remove(observer:)` into an | ||
| /// unbounded leak — one did, silently, until a large-wallet scan had | ||
| /// accumulated 3321 live `SyncModelImpl`s and the per-tick fan-out over | ||
| /// them became the dominant cost. Every observer is owned by whoever | ||
| /// created it (a view model, a `UIView`, a `@StateObject`), so the monitor | ||
| /// has no reason to keep any of them alive. | ||
| private let observers = NSHashTable<AnyObject>.weakObjects() | ||
| private let observersLock = NSLock() | ||
|
|
||
| override init() { | ||
|
|
@@ -199,22 +207,21 @@ class SyncingActivityMonitor: NSObject, NetworkReachabilityHandling { | |
| public func add(observer: SyncingActivityMonitorObserver) { | ||
| observersLock.lock() | ||
| defer { observersLock.unlock() } | ||
| observers.append(observer) | ||
| observers.add(observer) | ||
| } | ||
|
|
||
| @objc(removeObserver:) | ||
| public func remove(observer: SyncingActivityMonitorObserver) { | ||
| observersLock.lock() | ||
| defer { observersLock.unlock() } | ||
| if let idx = observers.firstIndex(where: { $0 === observer }) { | ||
| observers.remove(at: idx) | ||
| } | ||
| observers.remove(observer) | ||
| } | ||
|
|
||
| private func observerSnapshot() -> [SyncingActivityMonitorObserver] { | ||
| observersLock.lock() | ||
| defer { observersLock.unlock() } | ||
| return observers | ||
| // `allObjects` already drops entries whose object has gone away. | ||
| return observers.allObjects.compactMap { $0 as? SyncingActivityMonitorObserver } | ||
| } | ||
|
|
||
| deinit { | ||
|
|
@@ -304,9 +311,37 @@ extension SyncingActivityMonitor { | |
|
|
||
| applyProgressWithPeakSmoothing(sdkProgress) | ||
| isSyncing = (mapped == .syncing) | ||
| let wasDone = state == .syncDone | ||
| state = mapped | ||
|
|
||
| // `syncDone` is derived purely from the SPV network phases — it knows | ||
| // nothing about how much of what was scanned is durably persisted. | ||
| // The two can be far apart: the durable watermark is what a relaunch | ||
| // resumes from, and what the transaction list is built out of, so a | ||
| // wallet can report "synced" while rows are still materializing. | ||
| // | ||
| // Logged at the transition rather than gated on, because whether the | ||
| // watermark reliably reaches the tip is exactly what is unproven. One | ||
| // line per completion answers it from an ordinary session. | ||
| if mapped == .syncDone && !wasDone { | ||
| logDurableWatermarkAtCompletion(scannedTip: sdkSyncProgress.headers?.currentHeight ?? 0) | ||
| } | ||
| } | ||
|
|
||
| /// One-shot read of the persisted sync height at the moment the UI first | ||
| /// calls a sync complete, next to the height that was actually scanned. | ||
| /// A single fetch on a state transition — not a per-tick cost. | ||
| private func logDurableWatermarkAtCompletion(scannedTip: UInt32) { | ||
| guard let durable = SwiftDashSDKWalletSource.persistedSyncedHeight() else { | ||
| Self.logger.warning("⛓️ SYNCSTATE :: reported done at tip \(scannedTip, privacy: .public); durable watermark unavailable") | ||
| return | ||
| } | ||
| let behind = scannedTip > durable ? scannedTip - durable : 0 | ||
| Self.logger.info("⛓️ SYNCSTATE :: reported done — scanned tip \(scannedTip, privacy: .public), durable watermark \(durable, privacy: .public), behind by \(behind, privacy: .public) block(s)") | ||
|
Comment on lines
+327
to
+340
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Preserve unavailable and contradictory heights in the diagnostic. When The PR objective is to report the scanned tip, durable watermark, and block difference, so these fallback values should not hide missing data. 🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Wrap the completion log message. Line 334 exceeds the repository's 180-character Swift line limit. Break the message construction across shorter source lines while retaining the logged fields. As per coding guidelines, Swift files must use 4-space indentation and a 180-character line limit (100 recommended). 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
|
|
||
| private static let logger = Logger(subsystem: "org.dashfoundation.dash", category: "sync-state") | ||
|
|
||
| /// Map SwiftDashSDK's per-phase progress to the snapshot fields the | ||
| /// existing UI consumers expect. The phase priority order | ||
| /// (headers → filterHeaders → filters → masternodes → finished) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,20 @@ enum TransactionFilterCategory: CaseIterable { | |
| class HomeViewModel: ObservableObject { | ||
| private var cancellableBag = Set<AnyCancellable>() | ||
| private let queue = DispatchQueue(label: "HomeViewModel", qos: .userInitiated) | ||
| /// Whether a reconcile pass is queued or running. `queue` is serial, so | ||
| /// without this every trigger enqueues another full pass and they drain | ||
| /// back-to-back — each one re-reading the window and republishing the whole | ||
| /// list to the main thread for a result the pass behind it is about to | ||
| /// replace. Main-actor state, mutated only from `reloadTxDataSource` and | ||
| /// `finishReloadPass`. | ||
| private var reloadPassInFlight = false | ||
| /// A trigger that arrived while a pass was in flight. Collapsed to a | ||
| /// single follow-up pass, so a burst of N notifications costs two passes | ||
| /// (the one running plus one more that sees all of it), not N. | ||
| private var reloadPassRequestedAgain = false | ||
| /// Triggers folded into the current pass — logged so the next session's | ||
| /// numbers say how much this actually absorbs. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| private var reloadPassCoalesced = 0 | ||
| private var timeSkewDialogShown: Bool = false | ||
| /// Session guard so the proactive CoinJoin-sweep popup shows at most once | ||
| /// per launch (re-evaluated each launch while a leftover balance exists). | ||
|
|
@@ -229,6 +243,11 @@ class HomeViewModel: ObservableObject { | |
| // start immediately. | ||
| self.reloadTxsAndShortcuts() | ||
|
|
||
| // A fixture-backed model has no live data to observe. Subscribing | ||
| // anyway is what kept the onboarding demos reacting to every persister | ||
| // save and balance change for the whole session. | ||
| guard transactionSource.isLiveWalletSource else { return } | ||
|
|
||
| self.observeCoinJoinSweep() | ||
| self.observeWallet() | ||
| self.observeNetworkChange() | ||
|
|
@@ -515,6 +534,18 @@ class HomeViewModel: ObservableObject { | |
| // once up front, asynchronously, keeps the pass free-running. | ||
| let dispatch = { @MainActor [weak self] in | ||
| guard let self else { return } | ||
| // A pass already owns the queue: record that the world changed | ||
| // again and let it re-run once when it lands. Enqueueing here | ||
| // instead is what produced bursts of identical rebuilds — the | ||
| // reported duration of each includes the wait behind the ones | ||
| // before it, so a backlog reads as a slow pass and every entry in | ||
| // it republishes the full list. | ||
| guard !self.reloadPassInFlight else { | ||
| self.reloadPassRequestedAgain = true | ||
| self.reloadPassCoalesced += 1 | ||
| return | ||
| } | ||
| self.reloadPassInFlight = true | ||
| // Pair each request with the filter selection that was live when | ||
| // it was made. Reading it inside the pass instead would let a pass | ||
| // triggered by one change render a selection the user made after | ||
|
|
@@ -523,6 +554,7 @@ class HomeViewModel: ObservableObject { | |
| let startedAt = Date() | ||
| self.queue.async { [weak self] in | ||
| self?.performReload(selectedFilters: selectedFilters, startedAt: startedAt) | ||
| self?.finishReloadPass() | ||
| } | ||
| } | ||
| if Thread.isMainThread { | ||
|
|
@@ -532,7 +564,30 @@ class HomeViewModel: ObservableObject { | |
| } | ||
| } | ||
|
|
||
| /// Release the pass and, if the world moved while it ran, run exactly one | ||
| /// more. Called outside `performReload` so every early return in it — an | ||
| /// unbound host, a nil delta — still releases. | ||
| private func finishReloadPass() { | ||
| DispatchQueue.main.async { MainActor.assumeIsolated { [weak self] in | ||
| guard let self else { return } | ||
| let coalesced = self.reloadPassCoalesced | ||
| self.reloadPassCoalesced = 0 | ||
| self.reloadPassInFlight = false | ||
| if coalesced > 0 { | ||
| DWLogger.log("HomeViewModel: coalesced \(coalesced) reconcile trigger(s) into that pass") | ||
| } | ||
| guard self.reloadPassRequestedAgain else { return } | ||
| self.reloadPassRequestedAgain = false | ||
| self.reloadTxDataSource() | ||
| } } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| private func performReload(selectedFilters: Set<TransactionFilterCategory>, startedAt: Date) { | ||
| // `startedAt` is stamped when the pass was REQUESTED, on the main | ||
| // actor. `workStartedAt` is when it actually got the queue. Reporting | ||
| // only their sum reads a backlog as a slow pass — which is exactly how | ||
| // a burst of triggers used to look like one 21-second rebuild. | ||
| let workStartedAt = Date() | ||
| DWLogger.log("HomeViewModel: Starting timeline reconcile") | ||
|
|
||
| // --- Stage 1: bring the wrapped-row window up to date. Every | ||
|
|
@@ -586,7 +641,11 @@ class HomeViewModel: ObservableObject { | |
| } | ||
|
|
||
| // --- Stage 3: rebuild the visible list from the in-memory window. | ||
| rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: false) | ||
| rebuildTimelineItems( | ||
| selectedFilters: selectedFilters, | ||
| startedAt: startedAt, | ||
| workStartedAt: workStartedAt, | ||
| pageCompleted: false) | ||
| } | ||
|
|
||
| /// Replace the window cache with a freshly fetched first page. | ||
|
|
@@ -659,6 +718,7 @@ class HomeViewModel: ObservableObject { | |
| let startedAt = Date() | ||
| queue.async { [weak self] in | ||
| guard let self else { return } | ||
| let workStartedAt = Date() | ||
| guard self.hasOlderHistory, self.windowOldestDayStart > 0, | ||
| let page = self.transactionSource.olderTimelinePage( | ||
| endingBefore: self.windowOldestDayStart, | ||
|
|
@@ -678,7 +738,11 @@ class HomeViewModel: ObservableObject { | |
| // its rows' `lastUpdated` can postdate window updates the next | ||
| // delta still has to pick up. | ||
| DWLogger.log("HomeViewModel: Timeline paged to \(self.windowTxs.count) rows, older history: \(self.hasOlderHistory)") | ||
| self.rebuildTimelineItems(selectedFilters: selectedFilters, startedAt: startedAt, pageCompleted: true) | ||
| self.rebuildTimelineItems( | ||
| selectedFilters: selectedFilters, | ||
| startedAt: startedAt, | ||
| workStartedAt: workStartedAt, | ||
| pageCompleted: true) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -711,6 +775,7 @@ class HomeViewModel: ObservableObject { | |
| private func rebuildTimelineItems( | ||
| selectedFilters: Set<TransactionFilterCategory>, | ||
| startedAt: Date, | ||
| workStartedAt: Date, | ||
| pageCompleted: Bool | ||
| ) { | ||
| let transactions = timelineInputTransactions() | ||
|
|
@@ -846,8 +911,10 @@ class HomeViewModel: ObservableObject { | |
| return TransactionGroup(id: key, date: first.date, items: items) | ||
| }.sorted { $0.date > $1.date } | ||
|
|
||
| let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000) | ||
| DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms, \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") | ||
| let now = Date() | ||
| let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000) | ||
| let workMs = Int(now.timeIntervalSince(workStartedAt) * 1000) | ||
| DWLogger.log("HomeViewModel: Timeline rebuild complete in \(elapsedMs)ms (queued \(max(0, elapsedMs - workMs))ms, work \(workMs)ms), \(array.count) groups, \(self.txByHash.count) items cached, window \(windowTxs.count) rows") | ||
|
|
||
| publishTimeline( | ||
| array, | ||
|
|
@@ -904,7 +971,13 @@ class HomeViewModel: ObservableObject { | |
| pageCompleted: Bool | ||
| ) { | ||
| let canLoadMore = hasOlderHistory | ||
| let groupCount = array.count | ||
| DispatchQueue.main.async { | ||
| // The one part of a reconcile that is unavoidably main-thread: | ||
| // assigning `txItems` republishes the whole list and SwiftUI diffs | ||
| // it. Timed so the cost of republishing is a number rather than an | ||
| // inference. | ||
| let publishStartedAt = Date() | ||
| self.txItems = array | ||
| self.hasLoadedInitialTxItems = true | ||
| if self.canLoadMoreHistory != canLoadMore { | ||
|
|
@@ -920,6 +993,10 @@ class HomeViewModel: ObservableObject { | |
| if self.hasMasternodeHistory != hasMasternodes { | ||
| self.hasMasternodeHistory = hasMasternodes | ||
| } | ||
| let publishMs = Int(Date().timeIntervalSince(publishStartedAt) * 1000) | ||
| if publishMs >= 50 { | ||
| DWLogger.log("HomeViewModel: publish held the main thread \(publishMs)ms for \(groupCount) groups") | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1613,6 +1690,16 @@ struct WalletTimelineDelta { | |
| protocol TransactionSource { | ||
| var allTransactions: Array<Transaction> { get } | ||
|
|
||
| /// Whether this source is backed by the real wallet. | ||
| /// | ||
| /// A fixture source has nothing to learn from a persister save or a | ||
| /// balance change, so a view model built on one must not subscribe to | ||
| /// them: the onboarding demo screens outlive their own presentation, and | ||
| /// two such view models were found still rebuilding their four-row | ||
| /// fixture window on every notification twenty minutes into a session, | ||
| /// long after onboarding had finished. | ||
| var isLiveWalletSource: Bool { get } | ||
|
|
||
| /// The newest rows as a day-completed window of about `targetRowCount`. | ||
| /// Nil when the source has no active wallet yet. | ||
| func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? | ||
|
|
@@ -1634,6 +1721,10 @@ protocol TransactionSource { | |
| /// `allTransactions` set is one complete, already-loaded window; there is | ||
| /// nothing older to page in and no store to answer deltas or gates from. | ||
| extension TransactionSource { | ||
| /// Live unless a source opts out, so adding this cannot silently | ||
| /// disconnect a real one. | ||
| var isLiveWalletSource: Bool { true } | ||
|
Comment on lines
1776
to
+1779
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
fd -0 -e swift . DashWallet/Sources |
xargs -0 rg -n -P -C 4 \
'(class|struct|enum|extension)\s+\w+[^{}\n]*:\s*[^{}\n]*\bTransactionSource\b|\bisLiveWalletSource\b'Repository: dashpay/dashwallet-ios Length of output: 5895 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- tracked Swift references ---'
git ls-files '*.swift' |
xargs -r rg -n -C 3 '\bTransactionSource\b|\bisLiveWalletSource\b'
printf '%s\n' '--- declaration candidates ---'
python3 - <<'PY'
import re
import subprocess
files = subprocess.check_output(["git", "ls-files", "*.swift"], text=True).splitlines()
decl = re.compile(r'^\s*(?:private\s+|internal\s+|public\s+|fileprivate\s+|final\s+|open\s+|indirect\s+|actor\s+|nonisolated\s+)*'
r'(class|struct|enum|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^{]*)')
for path in files:
try:
lines = open(path, encoding="utf-8").read().splitlines()
except OSError:
continue
for n, line in enumerate(lines, 1):
m = decl.match(line)
if m and "TransactionSource" in m.group(3):
print(f"{path}:{n}: {line.strip()}")
PY
printf '%s\n' '--- relevant implementation ranges ---'
sed -n '1688,1745p;1898,1945p;2945,2970p' DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
sed -n '1,45p' DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swiftRepository: dashpay/dashwallet-ios Length of output: 47834 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- tracked Swift references ---'
git ls-files '*.swift' |
xargs -r rg -n -C 3 '\bTransactionSource\b|\bisLiveWalletSource\b'
printf '%s\n' '--- declaration candidates ---'
python3 - <<'PY'
import re
import subprocess
files = subprocess.check_output(["git", "ls-files", "*.swift"], text=True).splitlines()
decl = re.compile(
r'^\s*(?:private\s+|internal\s+|public\s+|fileprivate\s+|final\s+|open\s+|indirect\s+|actor\s+|nonisolated\s+)*'
r'(class|struct|enum|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^{]*)'
)
for path in files:
lines = open(path, encoding="utf-8").read().splitlines()
for n, line in enumerate(lines, 1):
match = decl.match(line)
if match and "TransactionSource" in match.group(3):
print(f"{path}:{n}: {line.strip()}")
PY
printf '%s\n' '--- relevant implementation ranges ---'
sed -n '1688,1745p;1898,1945p;2945,2970p' DashWallet/Sources/UI/Home/Views/HomeViewModel.swift
sed -n '1,45p' DashWallet/Sources/UI/Onboarding/Stubs/StubTransactionSource.swiftRepository: dashpay/dashwallet-ios Length of output: 47834 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import re
import subprocess
from pathlib import Path
files = subprocess.check_output(["git", "ls-files", "-z", "*.swift"]).decode().split("\0")
files = [f for f in files if f]
decl_re = re.compile(
r'^\s*(?:(?:private|internal|public|fileprivate|final|open|indirect|nonisolated)\s+)*'
r'(?:class|struct|enum|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^{]*)'
)
extension_re = re.compile(r'^\s*extension\s+([^{]+)')
property_re = re.compile(r'\bisLiveWalletSource\s*:\s*Bool\s*\{\s*(true|false)\s*\}')
declarations = []
references = []
for name in files:
text = Path(name).read_text(encoding="utf-8")
lines = text.splitlines()
for number, line in enumerate(lines, 1):
if "TransactionSource" in line:
references.append((name, number, line.strip()))
match = decl_re.match(line)
if match and "TransactionSource" in match.group(2):
declarations.append((name, number, match.group(1), line.strip()))
if "isLiveWalletSource" in line:
prop = property_re.search(line)
if prop:
print(f"PROPERTY {name}:{number}: {prop.group(1)}")
print("DECLARATIONS")
for item in declarations:
print(f"{item[0]}:{item[1]}: {item[2]} :: {item[3]}")
print("TRANSACTION_SOURCE_REFERENCE_COUNT", len(references))
print("REFERENCES")
for item in references:
print(f"{item[0]}:{item[1]}: {item[2]}")
PY
printf '%s\n' '--- preview construction and observer call sites ---'
rg -n -C 5 --glob '*.swift' \
'HomeViewModelPreviewTransactionSource|PreviewTransactionSource|HomeViewModel\(transactionSource:|observeCoinJoinSweep\(\)|observeWallet\(\)|observeNetworkChange\(\)' \
DashWalletRepository: dashpay/dashwallet-ios Length of output: 12161 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import re
import subprocess
from pathlib import Path
files = subprocess.check_output(["git", "ls-files", "-z", "*.swift"]).decode().split("\0")
files = [f for f in files if f]
decl_re = re.compile(
r'^\s*(?:(?:private|internal|public|fileprivate|final|open|indirect|nonisolated)\s+)*'
r'(?:class|struct|enum|actor)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^{]*)'
)
property_re = re.compile(r'\bisLiveWalletSource\s*:\s*Bool\s*\{\s*(true|false)\s*\}')
declarations = []
references = []
for name in files:
lines = Path(name).read_text(encoding="utf-8").splitlines()
for number, line in enumerate(lines, 1):
if "TransactionSource" in line:
references.append((name, number, line.strip()))
match = decl_re.match(line)
if match and "TransactionSource" in match.group(2):
declarations.append((name, number, match.group(1), line.strip()))
match = property_re.search(line)
if match:
print(f"PROPERTY {name}:{number}: {match.group(1)}")
print("DECLARATIONS")
for item in declarations:
print(f"{item[0]}:{item[1]}: {item[2]} :: {item[3]}")
print("TRANSACTION_SOURCE_REFERENCE_COUNT", len(references))
print("REFERENCES")
for item in references:
print(f"{item[0]}:{item[1]}: {item[2]}")
PY
printf '%s\n' '--- preview construction and observer call sites ---'
rg -n -C 5 --glob '*.swift' \
'HomeViewModelPreviewTransactionSource|PreviewTransactionSource|HomeViewModel\(transactionSource:|observeCoinJoinSweep\(\)|observeWallet\(\)|observeNetworkChange\(\)' \
DashWalletRepository: dashpay/dashwallet-ios Length of output: 12161 Mark It inherits 🤖 Prompt for AI Agents |
||
|
|
||
| func timelineWindow(targetRowCount: Int) -> WalletTimelineWindow? { | ||
| WalletTimelineWindow( | ||
| walletId: Data(), | ||
|
|
@@ -2370,6 +2461,21 @@ class SwiftDashSDKWalletSource: TransactionSource { | |
| /// cheap enough to block a worker queue on). `ModelContainer` is | ||
| /// `Sendable`, so the caller then opens its own `ModelContext` on its | ||
| /// own thread and all SwiftData work stays there. | ||
| /// The durable sync watermark the persister last wrote for the active | ||
| /// wallet — the height a relaunch resumes from, and the boundary below | ||
| /// which the transaction list is materialized. Distinct from the height | ||
| /// the SPV scan has reached, which is what the sync UI reports. | ||
| /// | ||
| /// One bounded fetch; call it on a state transition, not per tick. | ||
| static func persistedSyncedHeight() -> UInt32? { | ||
| guard let (container, walletId) = hostHandles() else { return nil } | ||
| let context = ModelContext(container) | ||
| var descriptor = FetchDescriptor<PersistentWallet>( | ||
| predicate: #Predicate { $0.walletId == walletId }) | ||
| descriptor.fetchLimit = 1 | ||
| return (try? context.fetch(descriptor))?.first?.syncedHeight | ||
| } | ||
|
|
||
| private static func hostHandles() -> (container: ModelContainer, walletId: Data)? { | ||
| onMain { | ||
| guard let container = SwiftDashSDKHost.shared.modelContainer, | ||
|
|
||
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.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: dashpay/dashwallet-ios
Length of output: 160
🏁 Script executed:
Repository: dashpay/dashwallet-ios
Length of output: 50379
🏁 Script executed:
Repository: dashpay/dashwallet-ios
Length of output: 50379
🏁 Script executed:
Repository: dashpay/dashwallet-ios
Length of output: 50379
🏁 Script executed:
Repository: dashpay/dashwallet-ios
Length of output: 50379
Bind completion logging to the subscribed wallet ID.
SyncingActivityMonitor.sharedreceives singleton SPV updates without awalletId, whilepersistedSyncedHeight()reads the active wallet at log time. After a wallet switch, a queued.syncDoneupdate can read the new wallet’s height or suppress its first completion log. Associate each completion with the wallet ID captured when its SPV progress subscription starts.🤖 Prompt for AI Agents
Source: Learnings