feat(sync): add pagination, cancellation, and network restoration
- CloudKit pagination: fetchAllRecords() handles >400 record batches with cursor-based pagination (400 records per page) - Cancellation support: SyncCancellationToken protocol enables graceful sync termination when background tasks expire - Per-entity progress: SyncState now tracks timestamps per entity type so interrupted syncs resume where they left off - NetworkMonitor: NWPathMonitor integration triggers sync on network restoration with 2.5s debounce to handle WiFi↔cellular flapping - wasCancelled flag in SyncResult distinguishes partial from full syncs This addresses critical data sync issues: - CloudKit queries were limited to ~400 records but bundled data has ~5000 games - Background tasks could be killed mid-sync without saving progress - App had no awareness of network state changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
import SwiftData
|
||||
import os
|
||||
|
||||
/// Manages background refresh and processing tasks for CloudKit sync.
|
||||
@MainActor
|
||||
@@ -29,6 +30,8 @@ final class BackgroundSyncManager {
|
||||
// MARK: - Properties
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
private var currentCancellationToken: BackgroundTaskCancellationToken?
|
||||
private let logger = Logger(subsystem: "com.sportstime.app", category: "BackgroundSyncManager")
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -130,29 +133,41 @@ final class BackgroundSyncManager {
|
||||
/// Handle the background refresh task.
|
||||
@MainActor
|
||||
private func handleRefreshTask(_ task: BGAppRefreshTask) async {
|
||||
print("Background refresh task started")
|
||||
logger.info("Background refresh task started")
|
||||
|
||||
// Schedule the next refresh before we start (in case we get terminated)
|
||||
scheduleRefresh()
|
||||
|
||||
// Set up expiration handler
|
||||
task.expirationHandler = {
|
||||
print("Background refresh task expired")
|
||||
task.setTaskCompleted(success: false)
|
||||
// Create cancellation token for this task
|
||||
let cancellationToken = BackgroundTaskCancellationToken()
|
||||
currentCancellationToken = cancellationToken
|
||||
|
||||
// Set up expiration handler - cancel sync gracefully
|
||||
task.expirationHandler = { [weak self] in
|
||||
self?.logger.warning("Background refresh task expiring - cancelling sync")
|
||||
cancellationToken.cancel()
|
||||
}
|
||||
|
||||
guard let container = modelContainer else {
|
||||
print("Background refresh: No model container configured")
|
||||
logger.error("Background refresh: No model container configured")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let success = try await performSync(context: container.mainContext)
|
||||
task.setTaskCompleted(success: success)
|
||||
print("Background refresh completed: \(success ? "success" : "no updates")")
|
||||
let result = try await performSync(context: container.mainContext, cancellationToken: cancellationToken)
|
||||
currentCancellationToken = nil
|
||||
|
||||
if result.wasCancelled {
|
||||
logger.info("Background refresh cancelled with partial progress: \(result.totalUpdated) items synced")
|
||||
task.setTaskCompleted(success: true) // Partial success - progress was saved
|
||||
} else {
|
||||
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
|
||||
task.setTaskCompleted(success: !result.isEmpty)
|
||||
}
|
||||
} catch {
|
||||
print("Background refresh failed: \(error.localizedDescription)")
|
||||
currentCancellationToken = nil
|
||||
logger.error("Background refresh failed: \(error.localizedDescription)")
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
@@ -160,34 +175,48 @@ final class BackgroundSyncManager {
|
||||
/// Handle the background processing task (overnight heavy sync).
|
||||
@MainActor
|
||||
private func handleProcessingTask(_ task: BGProcessingTask) async {
|
||||
print("Background processing task started")
|
||||
logger.info("Background processing task started")
|
||||
|
||||
// Schedule the next processing task
|
||||
scheduleProcessingTask()
|
||||
|
||||
// Set up expiration handler
|
||||
task.expirationHandler = {
|
||||
print("Background processing task expired")
|
||||
task.setTaskCompleted(success: false)
|
||||
// Create cancellation token for this task
|
||||
let cancellationToken = BackgroundTaskCancellationToken()
|
||||
currentCancellationToken = cancellationToken
|
||||
|
||||
// Set up expiration handler - cancel sync gracefully
|
||||
task.expirationHandler = { [weak self] in
|
||||
self?.logger.warning("Background processing task expiring - cancelling sync")
|
||||
cancellationToken.cancel()
|
||||
}
|
||||
|
||||
guard let container = modelContainer else {
|
||||
print("Background processing: No model container configured")
|
||||
logger.error("Background processing: No model container configured")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// 1. Full sync from CloudKit
|
||||
let syncSuccess = try await performSync(context: container.mainContext)
|
||||
let syncResult = try await performSync(context: container.mainContext, cancellationToken: cancellationToken)
|
||||
currentCancellationToken = nil
|
||||
|
||||
// 2. Clean up old data (games older than 1 year)
|
||||
let cleanupSuccess = await performCleanup(context: container.mainContext)
|
||||
// 2. Clean up old data (games older than 1 year) - only if sync wasn't cancelled
|
||||
var cleanupSuccess = false
|
||||
if !syncResult.wasCancelled {
|
||||
cleanupSuccess = await performCleanup(context: container.mainContext)
|
||||
}
|
||||
|
||||
task.setTaskCompleted(success: syncSuccess || cleanupSuccess)
|
||||
print("Background processing completed: sync=\(syncSuccess), cleanup=\(cleanupSuccess)")
|
||||
if syncResult.wasCancelled {
|
||||
logger.info("Background processing cancelled with partial progress: \(syncResult.totalUpdated) items synced")
|
||||
task.setTaskCompleted(success: true) // Partial success
|
||||
} else {
|
||||
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
|
||||
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
|
||||
}
|
||||
} catch {
|
||||
print("Background processing failed: \(error.localizedDescription)")
|
||||
currentCancellationToken = nil
|
||||
logger.error("Background processing failed: \(error.localizedDescription)")
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
@@ -195,30 +224,70 @@ final class BackgroundSyncManager {
|
||||
// MARK: - Sync Operations
|
||||
|
||||
/// Perform CloudKit sync operation.
|
||||
/// - Parameters:
|
||||
/// - context: The ModelContext to use
|
||||
/// - cancellationToken: Optional token for graceful cancellation
|
||||
/// - Returns: The sync result with counts and cancellation status
|
||||
@MainActor
|
||||
private func performSync(context: ModelContext) async throws -> Bool {
|
||||
private func performSync(
|
||||
context: ModelContext,
|
||||
cancellationToken: SyncCancellationToken? = nil
|
||||
) async throws -> CanonicalSyncService.SyncResult {
|
||||
let syncService = CanonicalSyncService()
|
||||
|
||||
do {
|
||||
let result = try await syncService.syncAll(context: context)
|
||||
let result = try await syncService.syncAll(context: context, cancellationToken: cancellationToken)
|
||||
|
||||
// Reload DataProvider if data changed
|
||||
if !result.isEmpty {
|
||||
if !result.isEmpty || result.wasCancelled {
|
||||
await AppDataProvider.shared.loadInitialData()
|
||||
print("Background sync: \(result.totalUpdated) items updated")
|
||||
return true
|
||||
logger.info("Sync completed: \(result.totalUpdated) items updated, cancelled: \(result.wasCancelled)")
|
||||
}
|
||||
|
||||
return false
|
||||
return result
|
||||
|
||||
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
|
||||
// No network - this is expected, not a failure
|
||||
print("Background sync: CloudKit unavailable (offline)")
|
||||
return false
|
||||
logger.info("CloudKit unavailable (offline)")
|
||||
return CanonicalSyncService.SyncResult(
|
||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
||||
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
|
||||
duration: 0, wasCancelled: false
|
||||
)
|
||||
} catch CanonicalSyncService.SyncError.syncAlreadyInProgress {
|
||||
// Another sync is running - that's fine
|
||||
print("Background sync: Already in progress")
|
||||
return false
|
||||
logger.info("Sync already in progress")
|
||||
return CanonicalSyncService.SyncResult(
|
||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
||||
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
|
||||
duration: 0, wasCancelled: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger sync from network restoration (called by NetworkMonitor).
|
||||
/// This is a non-background task sync, so no cancellation token is needed.
|
||||
@MainActor
|
||||
func triggerSyncFromNetworkRestoration() async {
|
||||
guard let container = modelContainer else {
|
||||
logger.error("Network sync: No model container configured")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Triggering sync from network restoration")
|
||||
|
||||
do {
|
||||
let result = try await performSync(context: container.mainContext)
|
||||
|
||||
if !result.isEmpty {
|
||||
logger.info("Network restoration sync completed: \(result.totalUpdated) items updated")
|
||||
} else {
|
||||
logger.info("Network restoration sync: no updates needed")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Network restoration sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user