Merge branch 'feature/sync-reliability'
This commit is contained in:
@@ -69,6 +69,15 @@ final class SyncState {
|
||||
var gameChangeToken: Data?
|
||||
var leagueChangeToken: Data?
|
||||
|
||||
// Per-entity sync timestamps for partial progress
|
||||
var lastStadiumSync: Date?
|
||||
var lastTeamSync: Date?
|
||||
var lastGameSync: Date?
|
||||
var lastLeagueStructureSync: Date?
|
||||
var lastTeamAliasSync: Date?
|
||||
var lastStadiumAliasSync: Date?
|
||||
var lastSportSync: Date?
|
||||
|
||||
init() {}
|
||||
|
||||
static func current(in context: ModelContext) -> SyncState {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,13 @@ actor CanonicalSyncService {
|
||||
let skippedIncompatible: Int
|
||||
let skippedOlder: Int
|
||||
let duration: TimeInterval
|
||||
let wasCancelled: Bool
|
||||
|
||||
var totalUpdated: Int {
|
||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated
|
||||
}
|
||||
|
||||
var isEmpty: Bool { totalUpdated == 0 }
|
||||
var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -69,8 +70,11 @@ actor CanonicalSyncService {
|
||||
|
||||
/// Perform a full sync of all canonical data types.
|
||||
/// This is the main entry point for background sync.
|
||||
/// - Parameters:
|
||||
/// - context: The ModelContext to use for saving data
|
||||
/// - cancellationToken: Optional token to check for cancellation between entity syncs
|
||||
@MainActor
|
||||
func syncAll(context: ModelContext) async throws -> SyncResult {
|
||||
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
|
||||
let startTime = Date()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
@@ -85,7 +89,7 @@ actor CanonicalSyncService {
|
||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
||||
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
|
||||
duration: 0
|
||||
duration: 0, wasCancelled: false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,73 +111,148 @@ actor CanonicalSyncService {
|
||||
var totalSports = 0
|
||||
var totalSkippedIncompatible = 0
|
||||
var totalSkippedOlder = 0
|
||||
var wasCancelled = false
|
||||
|
||||
/// Helper to save partial progress and check cancellation
|
||||
func saveProgressAndCheckCancellation() throws -> Bool {
|
||||
try context.save()
|
||||
if cancellationToken?.isCancelled == true {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
// Sync in dependency order
|
||||
// Sync in dependency order, checking cancellation between each entity type
|
||||
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiums = stadiums
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
syncState.lastStadiumSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalLeagueStructures = leagueStructures
|
||||
totalSkippedIncompatible += skipIncompat2
|
||||
totalSkippedOlder += skipOlder2
|
||||
syncState.lastLeagueStructureSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalTeams = teams
|
||||
totalSkippedIncompatible += skipIncompat3
|
||||
totalSkippedOlder += skipOlder3
|
||||
syncState.lastTeamSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalTeamAliases = teamAliases
|
||||
totalSkippedIncompatible += skipIncompat4
|
||||
totalSkippedOlder += skipOlder4
|
||||
syncState.lastTeamAliasSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalStadiumAliases = stadiumAliases
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
syncState.lastStadiumAliasSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (games, skipIncompat6, skipOlder6) = try await syncGames(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalGames = games
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
syncState.lastGameSync = Date()
|
||||
if try saveProgressAndCheckCancellation() {
|
||||
wasCancelled = true
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
totalSports = sports
|
||||
totalSkippedIncompatible += skipIncompat7
|
||||
totalSkippedOlder += skipOlder7
|
||||
syncState.lastSportSync = Date()
|
||||
|
||||
// Mark sync successful
|
||||
// Mark sync successful - clear per-entity timestamps since full sync completed
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSuccessfulSync = Date()
|
||||
syncState.lastSyncError = nil
|
||||
syncState.consecutiveFailures = 0
|
||||
// Clear per-entity timestamps - they're only needed for partial recovery
|
||||
syncState.lastStadiumSync = nil
|
||||
syncState.lastTeamSync = nil
|
||||
syncState.lastGameSync = nil
|
||||
syncState.lastLeagueStructureSync = nil
|
||||
syncState.lastTeamAliasSync = nil
|
||||
syncState.lastStadiumAliasSync = nil
|
||||
syncState.lastSportSync = nil
|
||||
|
||||
try context.save()
|
||||
|
||||
} catch is CancellationError {
|
||||
// Graceful cancellation - progress already saved
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSyncError = "Sync cancelled - partial progress saved"
|
||||
try? context.save()
|
||||
|
||||
return SyncResult(
|
||||
stadiumsUpdated: totalStadiums,
|
||||
teamsUpdated: totalTeams,
|
||||
gamesUpdated: totalGames,
|
||||
leagueStructuresUpdated: totalLeagueStructures,
|
||||
teamAliasesUpdated: totalTeamAliases,
|
||||
stadiumAliasesUpdated: totalStadiumAliases,
|
||||
sportsUpdated: totalSports,
|
||||
skippedIncompatible: totalSkippedIncompatible,
|
||||
skippedOlder: totalSkippedOlder,
|
||||
duration: Date().timeIntervalSince(startTime),
|
||||
wasCancelled: true
|
||||
)
|
||||
} catch {
|
||||
// Mark sync failed
|
||||
syncState.syncInProgress = false
|
||||
@@ -200,7 +279,8 @@ actor CanonicalSyncService {
|
||||
sportsUpdated: totalSports,
|
||||
skippedIncompatible: totalSkippedIncompatible,
|
||||
skippedOlder: totalSkippedOlder,
|
||||
duration: Date().timeIntervalSince(startTime)
|
||||
duration: Date().timeIntervalSince(startTime),
|
||||
wasCancelled: false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,10 +299,11 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncStadiums(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Delta sync: nil = all stadiums, Date = only modified since
|
||||
let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync)
|
||||
let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -249,10 +330,11 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncTeams(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Single call for all teams with delta sync
|
||||
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync)
|
||||
let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -280,10 +362,11 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncGames(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Delta sync: nil = all games, Date = only modified since
|
||||
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync)
|
||||
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -313,9 +396,10 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncLeagueStructure(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
|
||||
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -337,9 +421,10 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncTeamAliases(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
|
||||
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -361,9 +446,10 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncStadiumAliases(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
|
||||
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
@@ -385,9 +471,10 @@ actor CanonicalSyncService {
|
||||
@MainActor
|
||||
private func syncSports(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
since lastSync: Date?,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync)
|
||||
let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync, cancellationToken: cancellationToken)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
|
||||
@@ -65,11 +65,61 @@ actor CloudKitService {
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
/// Maximum records per CloudKit query (400 is the default limit)
|
||||
private let recordsPerPage = 400
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - Pagination Helper
|
||||
|
||||
/// Fetches all records matching a query using cursor-based pagination.
|
||||
/// Checks cancellation token between pages to allow graceful interruption.
|
||||
private func fetchAllRecords(
|
||||
matching query: CKQuery,
|
||||
cancellationToken: SyncCancellationToken?
|
||||
) async throws -> [CKRecord] {
|
||||
var allRecords: [CKRecord] = []
|
||||
var cursor: CKQueryOperation.Cursor?
|
||||
|
||||
// First page
|
||||
let (firstResults, firstCursor) = try await publicDatabase.records(
|
||||
matching: query,
|
||||
resultsLimit: recordsPerPage
|
||||
)
|
||||
|
||||
for result in firstResults {
|
||||
if case .success(let record) = result.1 {
|
||||
allRecords.append(record)
|
||||
}
|
||||
}
|
||||
cursor = firstCursor
|
||||
|
||||
// Continue fetching pages while cursor exists
|
||||
while let currentCursor = cursor {
|
||||
// Check cancellation between pages
|
||||
if cancellationToken?.isCancelled == true {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
let (results, nextCursor) = try await publicDatabase.records(
|
||||
continuingMatchFrom: currentCursor,
|
||||
resultsLimit: recordsPerPage
|
||||
)
|
||||
|
||||
for result in results {
|
||||
if case .success(let record) = result.1 {
|
||||
allRecords.append(record)
|
||||
}
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
// MARK: - Sync Types (include canonical IDs from CloudKit)
|
||||
|
||||
struct SyncStadium {
|
||||
@@ -214,8 +264,10 @@ actor CloudKitService {
|
||||
// MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit)
|
||||
|
||||
/// Fetch stadiums for sync operations
|
||||
/// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
||||
func fetchStadiumsForSync(since lastSync: Date?) async throws -> [SyncStadium] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
@@ -224,10 +276,9 @@ actor CloudKitService {
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result -> SyncStadium? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record -> SyncStadium? in
|
||||
let ckStadium = CKStadium(record: record)
|
||||
guard let stadium = ckStadium.stadium,
|
||||
let canonicalId = ckStadium.canonicalId
|
||||
@@ -237,8 +288,10 @@ actor CloudKitService {
|
||||
}
|
||||
|
||||
/// Fetch teams for sync operations
|
||||
/// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
|
||||
func fetchTeamsForSync(since lastSync: Date?) async throws -> [SyncTeam] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
@@ -247,10 +300,9 @@ actor CloudKitService {
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result -> SyncTeam? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record -> SyncTeam? in
|
||||
let ckTeam = CKTeam(record: record)
|
||||
guard let team = ckTeam.team,
|
||||
let canonicalId = ckTeam.canonicalId,
|
||||
@@ -261,8 +313,10 @@ actor CloudKitService {
|
||||
}
|
||||
|
||||
/// Fetch games for sync operations
|
||||
/// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date.
|
||||
func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all games. If provided, fetches only games modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
@@ -271,10 +325,9 @@ actor CloudKitService {
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result -> SyncGame? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record -> SyncGame? in
|
||||
let ckGame = CKGame(record: record)
|
||||
|
||||
guard let canonicalId = ckGame.canonicalId,
|
||||
@@ -359,7 +412,10 @@ actor CloudKitService {
|
||||
// MARK: - Delta Sync (Date-Based for Public Database)
|
||||
|
||||
/// Fetch league structure records modified after the given date
|
||||
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
@@ -370,16 +426,18 @@ actor CloudKitService {
|
||||
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record in
|
||||
return CKLeagueStructure(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch team alias records modified after the given date
|
||||
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
@@ -390,16 +448,18 @@ actor CloudKitService {
|
||||
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record in
|
||||
return CKTeamAlias(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch stadium alias records modified after the given date
|
||||
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
@@ -410,10 +470,9 @@ actor CloudKitService {
|
||||
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record in
|
||||
return CKStadiumAlias(record: record).toModel()
|
||||
}
|
||||
}
|
||||
@@ -421,8 +480,10 @@ actor CloudKitService {
|
||||
// MARK: - Sport Sync
|
||||
|
||||
/// Fetch sports for sync operations
|
||||
/// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||
func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] {
|
||||
/// - Parameters:
|
||||
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
@@ -431,10 +492,9 @@ actor CloudKitService {
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||
|
||||
return results.compactMap { result -> CanonicalSport? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return records.compactMap { record -> CanonicalSport? in
|
||||
return CKSport(record: record).toCanonical()
|
||||
}
|
||||
}
|
||||
|
||||
123
SportsTime/Core/Services/NetworkMonitor.swift
Normal file
123
SportsTime/Core/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// NetworkMonitor.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Monitors network connectivity using NWPathMonitor and triggers
|
||||
// sync when connectivity is restored after being offline.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
/// Monitors network state and triggers sync on connectivity restoration.
|
||||
@MainActor
|
||||
final class NetworkMonitor {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.sportstime.networkmonitor")
|
||||
private let logger = Logger(subsystem: "com.sportstime.app", category: "NetworkMonitor")
|
||||
|
||||
/// Current network status
|
||||
private(set) var isConnected: Bool = false
|
||||
|
||||
/// Whether the network has ever been in a disconnected state during this session
|
||||
private var hasBeenOffline: Bool = false
|
||||
|
||||
/// Debounce timer to avoid rapid sync triggers during network flapping
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
|
||||
/// Debounce delay in seconds (2.5s to handle WiFi↔cellular handoffs)
|
||||
private let debounceDelay: TimeInterval = 2.5
|
||||
|
||||
/// Callback for when sync should be triggered
|
||||
var onSyncNeeded: (() async -> Void)?
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start monitoring network state.
|
||||
/// Call this during app initialization.
|
||||
func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.handlePathUpdate(path)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
logger.info("Network monitoring started")
|
||||
}
|
||||
|
||||
/// Stop monitoring network state.
|
||||
/// Call this during app termination.
|
||||
func stopMonitoring() {
|
||||
monitor.cancel()
|
||||
debounceTask?.cancel()
|
||||
debounceTask = nil
|
||||
logger.info("Network monitoring stopped")
|
||||
}
|
||||
|
||||
// MARK: - Path Update Handler
|
||||
|
||||
private func handlePathUpdate(_ path: NWPath) async {
|
||||
let wasConnected = isConnected
|
||||
isConnected = path.status == .satisfied
|
||||
|
||||
logger.debug("Network path update: \(path.status == .satisfied ? "connected" : "disconnected")")
|
||||
|
||||
if !isConnected {
|
||||
// Mark that we've been offline
|
||||
hasBeenOffline = true
|
||||
// Cancel any pending sync trigger
|
||||
debounceTask?.cancel()
|
||||
debounceTask = nil
|
||||
logger.info("Network disconnected - sync will trigger on reconnection")
|
||||
} else if !wasConnected && hasBeenOffline {
|
||||
// We just reconnected after being offline
|
||||
logger.info("Network restored - scheduling debounced sync")
|
||||
scheduleDebouncedSync()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debounced Sync
|
||||
|
||||
private func scheduleDebouncedSync() {
|
||||
// Cancel any existing debounce task
|
||||
debounceTask?.cancel()
|
||||
|
||||
debounceTask = Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Wait for debounce delay
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(self.debounceDelay))
|
||||
} catch {
|
||||
// Task was cancelled (network went offline again or app terminating)
|
||||
return
|
||||
}
|
||||
|
||||
// Double-check we're still connected
|
||||
guard self.isConnected else {
|
||||
self.logger.debug("Network disconnected during debounce - cancelling sync")
|
||||
return
|
||||
}
|
||||
|
||||
self.logger.info("Triggering sync after network restoration")
|
||||
|
||||
// Reset the offline flag since we're now syncing
|
||||
self.hasBeenOffline = false
|
||||
|
||||
// Trigger the sync callback
|
||||
if let onSyncNeeded = self.onSyncNeeded {
|
||||
await onSyncNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
SportsTime/Core/Services/SyncCancellationToken.swift
Normal file
27
SportsTime/Core/Services/SyncCancellationToken.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// SyncCancellationToken.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Cancellation support for long-running sync operations.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Protocol for cancellation tokens checked between sync pages
|
||||
protocol SyncCancellationToken: Sendable {
|
||||
var isCancelled: Bool { get }
|
||||
}
|
||||
|
||||
/// Concrete cancellation token for background tasks
|
||||
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.withLock { $0 }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.withLock { $0 = true }
|
||||
}
|
||||
}
|
||||
@@ -169,13 +169,19 @@ struct BootstrappedContentView: View {
|
||||
await StoreManager.shared.loadProducts()
|
||||
await StoreManager.shared.updateEntitlements()
|
||||
|
||||
// 6. App is now usable
|
||||
// 6. Start network monitoring and wire up sync callback
|
||||
NetworkMonitor.shared.onSyncNeeded = {
|
||||
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
||||
}
|
||||
NetworkMonitor.shared.startMonitoring()
|
||||
|
||||
// 7. App is now usable
|
||||
isBootstrapping = false
|
||||
|
||||
// 7. Schedule background tasks for future syncs
|
||||
// 8. Schedule background tasks for future syncs
|
||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||
|
||||
// 8. Background: Try to refresh from CloudKit (non-blocking)
|
||||
// 9. Background: Try to refresh from CloudKit (non-blocking)
|
||||
Task.detached(priority: .background) {
|
||||
await self.performBackgroundSync(context: context)
|
||||
await MainActor.run {
|
||||
|
||||
Reference in New Issue
Block a user