Skip to content
328 changes: 227 additions & 101 deletions Bitkit/Services/MigrationsService.swift

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Bitkit/Utilities/AppReset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ enum AppReset {
toastType: Toast.ToastType = .success
) async throws {
try await PaykitSdkService.shared.withWalletWipe {
try await wipeLocal(app: app, wallet: wallet, session: session, toastType: toastType)
try await app.withMigrationSyncPausedForWipe {
try await wipeLocal(app: app, wallet: wallet, session: session, toastType: toastType)
}
}
}

Expand Down
109 changes: 84 additions & 25 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,28 @@ struct ScanPaymentOperations {
}
}

@MainActor
struct PostMigrationSyncOperations {
var syncPayments: () async -> Void
var markActivitiesSeen: () async -> Void
var reapplyMetadata: (Bool) async -> Void

static func live(migrations: MigrationsService) -> Self {
Self(
syncPayments: { try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? []) },
markActivitiesSeen: { _ = await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() },
reapplyMetadata: { await migrations.reapplyMetadataAfterSync(includeLocalMetadata: $0) }
)
}
}

@MainActor
class AppViewModel: ObservableObject {
private var isSyncingMigration = false
private var isResettingMigration = false
private(set) var postMigrationSyncTask: Task<Void, Never>?
private let migrations: MigrationsService
private let postMigrationSyncOperations: PostMigrationSyncOperations
// Send flow
@Published var scannedLightningInvoice: LightningInvoice?
@Published var scannedOnchainInvoice: OnChainInvoice?
Expand Down Expand Up @@ -239,8 +259,12 @@ class AppViewModel: ObservableObject {
coreService: CoreService = .shared,
sheetViewModel: SheetViewModel,
navigationViewModel: NavigationViewModel,
scanPaymentOperations: ScanPaymentOperations? = nil
scanPaymentOperations: ScanPaymentOperations? = nil,
migrations: MigrationsService = .shared,
postMigrationSyncOperations: PostMigrationSyncOperations? = nil
) {
self.migrations = migrations
self.postMigrationSyncOperations = postMigrationSyncOperations ?? .live(migrations: migrations)
self.lightningService = lightningService
self.coreService = coreService
self.sheetViewModel = sheetViewModel
Expand Down Expand Up @@ -1367,6 +1391,64 @@ extension AppViewModel {
}
}

/// Invalidates captured retries and drains queued activity writes before databases/defaults are wiped.
func cancelMigrationSyncForWipe() async {
isResettingMigration = true
Comment thread
greptile-apps[bot] marked this conversation as resolved.
migrations.invalidatePendingRetries()
postMigrationSyncTask?.cancel()
await postMigrationSyncTask?.value
}

/// Keep the retiring app paused on success, but resume retained metadata if the wipe aborts.
func withMigrationSyncPausedForWipe(_ wipe: () async throws -> Void) async rethrows {
await cancelMigrationSyncForWipe()
do {
try await wipe()
} catch {
isResettingMigration = false
throw error
}
}

private func beginPostMigrationSync() {
guard !isResettingMigration, !isSyncingMigration,
migrations.needsPostMigrationSync || migrations.shouldRetryPendingMigrationData
else { return }
let completingMigration = migrations.needsPostMigrationSync
let generation = migrations.retryGeneration
isSyncingMigration = true
postMigrationSyncTask = Task { @MainActor in
defer {
self.isSyncingMigration = false
self.postMigrationSyncTask = nil
}
guard migrations.isRetryCurrent(generation) else { return }
if completingMigration {
await postMigrationSyncOperations.syncPayments()
guard migrations.isRetryCurrent(generation) else { return }
await postMigrationSyncOperations.markActivitiesSeen()
guard migrations.isRetryCurrent(generation) else { return }
}
await postMigrationSyncOperations.reapplyMetadata(completingMigration)
guard migrations.isRetryCurrent(generation) else { return }
migrations.needsPostMigrationSync = false
migrations.recordPendingMigrationRetry()

if migrations.canCleanupAfterMigration {
if migrations.isShowingMigrationLoading {
try? await LightningService.shared.restart()
guard migrations.isRetryCurrent(generation) else { return }
}
SettingsViewModel.shared.updatePinEnabledState()
migrations.cleanupAfterMigration()
} else {
Logger.info("Post-migration sync incomplete, pending data will retry with backoff", context: "AppViewModel")
}
migrations.isRestoringFromRNRemoteBackup = false
migrations.isShowingMigrationLoading = false
}
}

func handleLdkNodeEvent(_ event: Event) {
switch event {
case let .paymentReceived(paymentId, _, amountMsat, _):
Expand Down Expand Up @@ -1609,30 +1691,7 @@ extension AppViewModel {
}
}

if MigrationsService.shared.needsPostMigrationSync {
Task { @MainActor in
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? [])
await CoreService.shared.activity.markAllUnseenActivitiesAsSeen()
await MigrationsService.shared.reapplyMetadataAfterSync()

if MigrationsService.shared.canCleanupAfterMigration {
if MigrationsService.shared.isShowingMigrationLoading {
try? await LightningService.shared.restart()
}

SettingsViewModel.shared.updatePinEnabledState()
MigrationsService.shared.cleanupAfterMigration()
MigrationsService.shared.needsPostMigrationSync = false
MigrationsService.shared.isRestoringFromRNRemoteBackup = false
} else {
Logger.info("Post-migration sync incomplete, will retry on next sync", context: "AppViewModel")
}

if MigrationsService.shared.isShowingMigrationLoading {
MigrationsService.shared.isShowingMigrationLoading = false
}
}
}
beginPostMigrationSync()

// MARK: Balance Events

Expand Down
75 changes: 75 additions & 0 deletions BitkitTests/RNMigrationRetryScheduleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@testable import Bitkit
import XCTest

@MainActor
final class RNMigrationRetryScheduleTests: XCTestCase {
func testBackoffSurvivesReloadAndStopsSyncFrequencyRetries() throws {
let suite = "RNMigrationBackoff.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
var now = Date(timeIntervalSince1970: 1000)
let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now })
service.pendingMetadata = Bitkit.RNMetadata(tags: ["missing": ["retained"]])
XCTAssertTrue(service.shouldRetryPendingMigrationData)
service.recordPendingMigrationRetry()

for _ in 0 ..< 5 {
now.addTimeInterval(10)
XCTAssertFalse(service.shouldRetryPendingMigrationData)
}
let reloaded = Bitkit.MigrationsService(userDefaults: defaults, now: { now })
XCTAssertFalse(reloaded.shouldRetryPendingMigrationData)
XCTAssertEqual(reloaded.pendingMetadata?.tags, ["missing": ["retained"]])
now.addTimeInterval(10)
XCTAssertTrue(reloaded.shouldRetryPendingMigrationData)
reloaded.recordPendingMigrationRetry()
now.addTimeInterval(119)
XCTAssertFalse(service.shouldRetryPendingMigrationData)
now.addTimeInterval(1)
XCTAssertTrue(service.shouldRetryPendingMigrationData)
}

func testRetriesReachHourlyCapWithoutDroppingMarkersAndRecoverLateTags() async throws {
let suite = "RNMigrationBackoffRecovery.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
var now = Date(timeIntervalSince1970: 1000)
let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now })
service.pendingMetadata = Bitkit.RNMetadata(tags: ["late": ["retained"]])
for delay in [60.0, 120, 240, 480, 960, 1920, 3600, 3600] {
XCTAssertTrue(service.shouldRetryPendingMigrationData)
service.recordPendingMigrationRetry()
now.addTimeInterval(delay - 1)
XCTAssertFalse(service.shouldRetryPendingMigrationData)
now.addTimeInterval(1)
XCTAssertTrue(service.shouldRetryPendingMigrationData)
XCTAssertEqual(service.pendingMetadata?.tags, ["late": ["retained"]])
}
var applied: [String: [String]] = [:]
await service.retryPendingMetadata { tags in
applied = tags
return []
}
service.recordPendingMigrationRetry()
XCTAssertEqual(applied, ["late": ["retained"]])
XCTAssertFalse(service.hasPendingMigrationRetries)
XCTAssertNil(defaults.data(forKey: "rnMigrationRetrySchedule"))
service.pendingRemoteTransfers = ["new-transfer": "channel"]
XCTAssertTrue(service.shouldRetryPendingMigrationData, "a completed schedule must not delay newly pending data")
}

func testClockRollbackDoesNotStarveRetries() throws {
let suite = "RNMigrationBackoffClock.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
var now = Date(timeIntervalSince1970: 1000)
let service = Bitkit.MigrationsService(userDefaults: defaults, now: { now })
service.pendingRemoteTransfers = ["transfer": "channel"]
service.recordPendingMigrationRetry()
XCTAssertFalse(service.shouldRetryPendingMigrationData)
now.addTimeInterval(-1)
XCTAssertTrue(service.shouldRetryPendingMigrationData)
service.recordPendingMigrationRetry()
XCTAssertFalse(service.shouldRetryPendingMigrationData)
}
}
Loading
Loading