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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user