docs: add comprehensive sync reliability documentation
Adds SYNC_RELIABILITY.md covering: - Architecture overview with diagrams - Component documentation (CloudKitService, CanonicalSyncService, BackgroundSyncManager, NetworkMonitor, SyncCancellationToken) - Data flow diagrams for all sync triggers - Error handling strategies - Debugging guide with LLDB commands - Testing checklist - Performance considerations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
759
docs/SYNC_RELIABILITY.md
Normal file
759
docs/SYNC_RELIABILITY.md
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
# Sync Reliability Architecture
|
||||||
|
|
||||||
|
This document describes the CloudKit-to-SwiftData synchronization system in SportsTime, including pagination, cancellation support, network monitoring, and background task integration.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Components](#components)
|
||||||
|
- [CloudKitService](#cloudkitservice)
|
||||||
|
- [CanonicalSyncService](#canonicalsyncservice)
|
||||||
|
- [BackgroundSyncManager](#backgroundsyncmanager)
|
||||||
|
- [NetworkMonitor](#networkmonitor)
|
||||||
|
- [SyncCancellationToken](#synccancellationtoken)
|
||||||
|
- [Data Flow](#data-flow)
|
||||||
|
- [Sync Triggers](#sync-triggers)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Debugging](#debugging)
|
||||||
|
- [Testing](#testing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SportsTime uses an offline-first architecture where:
|
||||||
|
|
||||||
|
1. **Bundled JSON** provides initial data on first launch
|
||||||
|
2. **SwiftData** stores all canonical data locally
|
||||||
|
3. **CloudKit** (public database) is the authoritative source for schedule updates
|
||||||
|
4. **Background sync** keeps local data fresh without user intervention
|
||||||
|
|
||||||
|
The sync system is designed to be:
|
||||||
|
|
||||||
|
- **Resilient**: Handles network failures, partial syncs, and interrupted background tasks
|
||||||
|
- **Efficient**: Uses cursor-based pagination to handle large datasets (5000+ games)
|
||||||
|
- **Non-blocking**: Never prevents the user from using the app
|
||||||
|
- **Battery-conscious**: Debounces network changes and schedules heavy work overnight
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Sync Triggers │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ App Launch │ Foreground │ Network Restore │ Background Task │
|
||||||
|
│ (bootstrap) │ (resume) │ (NetworkMonitor) │ (BGTaskScheduler) │
|
||||||
|
└───────┬────────┴──────┬───────┴─────────┬─────────┴───────────┬─────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
└───────────────┴─────────────────┴─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ CanonicalSyncService │
|
||||||
|
│ │
|
||||||
|
│ • Orchestrates sync across all entity types │
|
||||||
|
│ • Tracks per-entity progress in SyncState │
|
||||||
|
│ • Handles cancellation for graceful termination │
|
||||||
|
│ • Returns SyncResult with counts and status │
|
||||||
|
└─────────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ CloudKitService │
|
||||||
|
│ │
|
||||||
|
│ • Fetches records from CloudKit public database │
|
||||||
|
│ • Uses cursor-based pagination (400 records/page) │
|
||||||
|
│ • Supports cancellation between pages │
|
||||||
|
│ • Parses CKRecord → Domain models │
|
||||||
|
└─────────────────────────┬───────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ SwiftData │
|
||||||
|
│ │
|
||||||
|
│ • Canonical models: Stadium, Team, Game, etc. │
|
||||||
|
│ • SyncState: tracks last sync times per entity │
|
||||||
|
│ • Persists across app launches │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### CloudKitService
|
||||||
|
|
||||||
|
**File:** `SportsTime/Core/Services/CloudKitService.swift`
|
||||||
|
|
||||||
|
The CloudKitService handles all communication with CloudKit's public database. It provides type-safe fetch methods for each entity type.
|
||||||
|
|
||||||
|
#### Pagination
|
||||||
|
|
||||||
|
CloudKit queries are limited to returning approximately 400 records per request. With 5000+ games in the database, pagination is essential.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private let recordsPerPage = 400
|
||||||
|
|
||||||
|
private func fetchAllRecords(
|
||||||
|
matching query: CKQuery,
|
||||||
|
cancellationToken: SyncCancellationToken?
|
||||||
|
) async throws -> [CKRecord] {
|
||||||
|
var allRecords: [CKRecord] = []
|
||||||
|
var cursor: CKQueryOperation.Cursor?
|
||||||
|
|
||||||
|
// Fetch first page
|
||||||
|
let (firstResults, firstCursor) = try await publicDatabase.records(
|
||||||
|
matching: query,
|
||||||
|
resultsLimit: recordsPerPage
|
||||||
|
)
|
||||||
|
// Process results...
|
||||||
|
cursor = firstCursor
|
||||||
|
|
||||||
|
// Continue fetching while cursor exists
|
||||||
|
while let currentCursor = cursor {
|
||||||
|
// Check for cancellation between pages
|
||||||
|
if cancellationToken?.isCancelled == true {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
let (results, nextCursor) = try await publicDatabase.records(
|
||||||
|
continuingMatchFrom: currentCursor,
|
||||||
|
resultsLimit: recordsPerPage
|
||||||
|
)
|
||||||
|
// Process results...
|
||||||
|
cursor = nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecords
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fetch Methods
|
||||||
|
|
||||||
|
Each entity type has a dedicated fetch method:
|
||||||
|
|
||||||
|
| Method | Entity | Record Type |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| `fetchStadiumsForSync(since:cancellationToken:)` | Stadium | `Stadium` |
|
||||||
|
| `fetchTeamsForSync(since:cancellationToken:)` | Team | `Team` |
|
||||||
|
| `fetchGamesForSync(since:cancellationToken:)` | Game | `Game` |
|
||||||
|
| `fetchLeagueStructureChanges(since:cancellationToken:)` | LeagueStructure | `LeagueStructure` |
|
||||||
|
| `fetchTeamAliasChanges(since:cancellationToken:)` | TeamAlias | `TeamAlias` |
|
||||||
|
| `fetchStadiumAliasChanges(since:cancellationToken:)` | StadiumAlias | `StadiumAlias` |
|
||||||
|
| `fetchSportsForSync(since:cancellationToken:)` | DynamicSport | `Sport` |
|
||||||
|
|
||||||
|
All methods accept a `since` date for incremental sync and a `cancellationToken` for graceful termination.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CanonicalSyncService
|
||||||
|
|
||||||
|
**File:** `SportsTime/Core/Services/CanonicalSyncService.swift`
|
||||||
|
|
||||||
|
The CanonicalSyncService orchestrates the sync process across all entity types, managing progress tracking and cancellation.
|
||||||
|
|
||||||
|
#### SyncResult
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct SyncResult {
|
||||||
|
let stadiumsUpdated: Int
|
||||||
|
let teamsUpdated: Int
|
||||||
|
let gamesUpdated: Int
|
||||||
|
let leagueStructuresUpdated: Int
|
||||||
|
let teamAliasesUpdated: Int
|
||||||
|
let stadiumAliasesUpdated: Int
|
||||||
|
let sportsUpdated: Int
|
||||||
|
let skippedIncompatible: Int
|
||||||
|
let skippedOlder: Int
|
||||||
|
let duration: TimeInterval
|
||||||
|
let wasCancelled: Bool // True if sync was interrupted
|
||||||
|
|
||||||
|
var isEmpty: Bool { totalUpdated == 0 && !wasCancelled }
|
||||||
|
var totalUpdated: Int { /* sum of all updated counts */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sync Order
|
||||||
|
|
||||||
|
Entities are synced in dependency order:
|
||||||
|
|
||||||
|
1. **Sports** - Must exist before teams reference them
|
||||||
|
2. **Stadiums** - Must exist before teams reference them
|
||||||
|
3. **Teams** - Must exist before games reference them
|
||||||
|
4. **Games** - References teams and stadiums
|
||||||
|
5. **League Structures** - References teams
|
||||||
|
6. **Team Aliases** - References teams
|
||||||
|
7. **Stadium Aliases** - References stadiums
|
||||||
|
|
||||||
|
#### Per-Entity Progress
|
||||||
|
|
||||||
|
Progress is saved after each entity type completes:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
|
||||||
|
// 1. Sync sports
|
||||||
|
let sportsResult = try await syncSports(context: context, cancellationToken: cancellationToken)
|
||||||
|
syncState.lastSportSync = Date()
|
||||||
|
try context.save() // Save progress
|
||||||
|
|
||||||
|
// Check for cancellation
|
||||||
|
if cancellationToken?.isCancelled == true {
|
||||||
|
return SyncResult(/* ... */, wasCancelled: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Sync stadiums
|
||||||
|
let stadiumsResult = try await syncStadiums(context: context, cancellationToken: cancellationToken)
|
||||||
|
syncState.lastStadiumSync = Date()
|
||||||
|
try context.save() // Save progress
|
||||||
|
|
||||||
|
// ... continue for each entity type
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures that if a background task is terminated mid-sync, the next sync will only fetch entities that weren't completed.
|
||||||
|
|
||||||
|
#### Sync Mutex
|
||||||
|
|
||||||
|
Only one sync can run at a time:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private var isSyncing = false
|
||||||
|
|
||||||
|
func syncAll(context: ModelContext, cancellationToken: SyncCancellationToken? = nil) async throws -> SyncResult {
|
||||||
|
guard !isSyncing else {
|
||||||
|
throw SyncError.syncAlreadyInProgress
|
||||||
|
}
|
||||||
|
isSyncing = true
|
||||||
|
defer { isSyncing = false }
|
||||||
|
// ... sync logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BackgroundSyncManager
|
||||||
|
|
||||||
|
**File:** `SportsTime/Core/Services/BackgroundSyncManager.swift`
|
||||||
|
|
||||||
|
The BackgroundSyncManager integrates with iOS's BGTaskScheduler to run sync operations when the app is not active.
|
||||||
|
|
||||||
|
#### Task Types
|
||||||
|
|
||||||
|
| Task | Identifier | Purpose | Schedule |
|
||||||
|
|------|------------|---------|----------|
|
||||||
|
| Refresh | `com.sportstime.app.refresh` | Periodic sync | Every ~1 hour (system decides) |
|
||||||
|
| Processing | `com.sportstime.app.db-cleanup` | Heavy sync + cleanup | Overnight (~2 AM) |
|
||||||
|
|
||||||
|
#### Registration
|
||||||
|
|
||||||
|
Tasks must be registered before `applicationDidFinishLaunching` returns:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// In SportsTimeApp.init()
|
||||||
|
BackgroundSyncManager.shared.registerTasks()
|
||||||
|
```
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func registerTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: Self.refreshTaskIdentifier,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let task = task as? BGAppRefreshTask else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
await self.handleRefreshTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... register processing task
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cancellation Integration
|
||||||
|
|
||||||
|
When the system needs to terminate a background task, the expiration handler fires:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private func handleRefreshTask(_ task: BGAppRefreshTask) async {
|
||||||
|
// Create cancellation token
|
||||||
|
let cancellationToken = BackgroundTaskCancellationToken()
|
||||||
|
currentCancellationToken = cancellationToken
|
||||||
|
|
||||||
|
// Set up expiration handler
|
||||||
|
task.expirationHandler = { [weak self] in
|
||||||
|
self?.logger.warning("Background refresh task expiring - cancelling sync")
|
||||||
|
cancellationToken.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync with cancellation support
|
||||||
|
let result = try await performSync(
|
||||||
|
context: container.mainContext,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle result
|
||||||
|
if result.wasCancelled {
|
||||||
|
// Partial success - progress was saved
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} else {
|
||||||
|
task.setTaskCompleted(success: !result.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scheduling
|
||||||
|
|
||||||
|
Background tasks are scheduled when:
|
||||||
|
- App finishes bootstrapping
|
||||||
|
- App enters background
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func scheduleAllTasks() {
|
||||||
|
scheduleRefresh()
|
||||||
|
scheduleProcessingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleProcessingTask() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier)
|
||||||
|
request.earliestBeginDate = /* next 2 AM */
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NetworkMonitor
|
||||||
|
|
||||||
|
**File:** `SportsTime/Core/Services/NetworkMonitor.swift`
|
||||||
|
|
||||||
|
The NetworkMonitor uses NWPathMonitor to detect network state changes and trigger sync when connectivity is restored.
|
||||||
|
|
||||||
|
#### Why Network Monitoring?
|
||||||
|
|
||||||
|
Without network monitoring, the app would only sync:
|
||||||
|
- On app launch
|
||||||
|
- On background task execution (system-controlled)
|
||||||
|
|
||||||
|
With network monitoring, the app syncs immediately when:
|
||||||
|
- User exits airplane mode
|
||||||
|
- WiFi reconnects after being out of range
|
||||||
|
- Cellular restores after being in a dead zone
|
||||||
|
|
||||||
|
#### Debouncing
|
||||||
|
|
||||||
|
Network state can flap rapidly during WiFi↔cellular handoffs. A 2.5-second debounce prevents multiple sync triggers:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private let debounceDelay: TimeInterval = 2.5
|
||||||
|
|
||||||
|
private func scheduleDebouncedSync() {
|
||||||
|
debounceTask?.cancel()
|
||||||
|
|
||||||
|
debounceTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
// Wait for debounce delay
|
||||||
|
try await Task.sleep(for: .seconds(debounceDelay))
|
||||||
|
|
||||||
|
// Verify still connected
|
||||||
|
guard isConnected else { return }
|
||||||
|
|
||||||
|
// Reset offline flag and trigger sync
|
||||||
|
hasBeenOffline = false
|
||||||
|
await onSyncNeeded?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ App Starts │
|
||||||
|
│ hasBeenOffline │
|
||||||
|
│ = false │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
┌────────│ Monitoring │────────┐
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
Disconnected Connected
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ hasBeenOffline │ │
|
||||||
|
│ = true │ │
|
||||||
|
│ Cancel debounce │ │
|
||||||
|
└────────┬────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Reconnected │
|
||||||
|
└──────────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Start debounce │
|
||||||
|
│ (2.5 sec) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┴──────────────┐
|
||||||
|
│ │
|
||||||
|
Still connected Disconnected again
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Trigger sync │ │ Cancel debounce │
|
||||||
|
│ hasBeenOffline │ │ Wait for next │
|
||||||
|
│ = false │ │ reconnection │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Integration
|
||||||
|
|
||||||
|
The NetworkMonitor is wired up during app bootstrap:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// In BootstrappedContentView.performBootstrap()
|
||||||
|
NetworkMonitor.shared.onSyncNeeded = {
|
||||||
|
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
||||||
|
}
|
||||||
|
NetworkMonitor.shared.startMonitoring()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SyncCancellationToken
|
||||||
|
|
||||||
|
**File:** `SportsTime/Core/Services/SyncCancellationToken.swift`
|
||||||
|
|
||||||
|
The SyncCancellationToken protocol enables graceful cancellation of sync operations.
|
||||||
|
|
||||||
|
#### Protocol
|
||||||
|
|
||||||
|
```swift
|
||||||
|
protocol SyncCancellationToken: Sendable {
|
||||||
|
var isCancelled: Bool { get }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
```swift
|
||||||
|
final class BackgroundTaskCancellationToken: SyncCancellationToken, @unchecked Sendable {
|
||||||
|
private let lock = OSAllocatedUnfairLock(initialState: false)
|
||||||
|
|
||||||
|
var isCancelled: Bool {
|
||||||
|
lock.withLock { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
lock.withLock { $0 = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The implementation uses `OSAllocatedUnfairLock` for thread-safe access:
|
||||||
|
- The expiration handler runs on an arbitrary thread
|
||||||
|
- The sync operation runs on the MainActor
|
||||||
|
- The lock ensures both can safely access `isCancelled`
|
||||||
|
|
||||||
|
#### Usage in Sync
|
||||||
|
|
||||||
|
Cancellation is checked at strategic points:
|
||||||
|
|
||||||
|
1. **Between entity types** - After completing stadiums, check before starting teams
|
||||||
|
2. **Between pagination pages** - After fetching 400 records, check before fetching the next page
|
||||||
|
3. **Never mid-entity** - A single entity's sync always completes atomically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### App Launch Sync
|
||||||
|
|
||||||
|
```
|
||||||
|
SportsTimeApp.init()
|
||||||
|
│
|
||||||
|
└─► BackgroundSyncManager.registerTasks()
|
||||||
|
|
||||||
|
BootstrappedContentView.task
|
||||||
|
│
|
||||||
|
└─► performBootstrap()
|
||||||
|
│
|
||||||
|
├─► BootstrapService.bootstrapIfNeeded() // First launch only
|
||||||
|
│
|
||||||
|
├─► AppDataProvider.configure()
|
||||||
|
│
|
||||||
|
├─► BackgroundSyncManager.configure()
|
||||||
|
│
|
||||||
|
├─► AppDataProvider.loadInitialData()
|
||||||
|
│
|
||||||
|
├─► NetworkMonitor.startMonitoring()
|
||||||
|
│
|
||||||
|
├─► BackgroundSyncManager.scheduleAllTasks()
|
||||||
|
│
|
||||||
|
└─► Task.detached {
|
||||||
|
performBackgroundSync() // Non-blocking
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Foreground Sync
|
||||||
|
|
||||||
|
```
|
||||||
|
scenePhase changes to .active (and hasCompletedInitialSync)
|
||||||
|
│
|
||||||
|
└─► performBackgroundSync()
|
||||||
|
│
|
||||||
|
└─► CanonicalSyncService.syncAll()
|
||||||
|
│
|
||||||
|
├─► CloudKitService.fetchSportsForSync()
|
||||||
|
├─► CloudKitService.fetchStadiumsForSync()
|
||||||
|
├─► CloudKitService.fetchTeamsForSync()
|
||||||
|
├─► CloudKitService.fetchGamesForSync()
|
||||||
|
├─► CloudKitService.fetchLeagueStructureChanges()
|
||||||
|
├─► CloudKitService.fetchTeamAliasChanges()
|
||||||
|
└─► CloudKitService.fetchStadiumAliasChanges()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Task Sync
|
||||||
|
|
||||||
|
```
|
||||||
|
BGTaskScheduler triggers task
|
||||||
|
│
|
||||||
|
└─► BackgroundSyncManager.handleRefreshTask()
|
||||||
|
│
|
||||||
|
├─► Create cancellationToken
|
||||||
|
│
|
||||||
|
├─► Set expirationHandler → cancellationToken.cancel()
|
||||||
|
│
|
||||||
|
├─► performSync(cancellationToken: cancellationToken)
|
||||||
|
│ │
|
||||||
|
│ └─► CanonicalSyncService.syncAll(cancellationToken: token)
|
||||||
|
│ │
|
||||||
|
│ ├─► Sync sports → save progress
|
||||||
|
│ ├─► Check cancellation
|
||||||
|
│ ├─► Sync stadiums → save progress
|
||||||
|
│ ├─► Check cancellation
|
||||||
|
│ └─► ... (continues until complete or cancelled)
|
||||||
|
│
|
||||||
|
└─► task.setTaskCompleted(success: result.wasCancelled || !result.isEmpty)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Restoration Sync
|
||||||
|
|
||||||
|
```
|
||||||
|
NWPathMonitor detects connectivity
|
||||||
|
│
|
||||||
|
└─► NetworkMonitor.handlePathUpdate()
|
||||||
|
│
|
||||||
|
└─► scheduleDebouncedSync()
|
||||||
|
│
|
||||||
|
└─► (after 2.5s delay)
|
||||||
|
│
|
||||||
|
└─► onSyncNeeded()
|
||||||
|
│
|
||||||
|
└─► BackgroundSyncManager.triggerSyncFromNetworkRestoration()
|
||||||
|
│
|
||||||
|
└─► performSync()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Triggers
|
||||||
|
|
||||||
|
| Trigger | When | Cancellable | Notes |
|
||||||
|
|---------|------|-------------|-------|
|
||||||
|
| App Launch | `performBootstrap()` | No | Non-blocking, runs in background |
|
||||||
|
| Foreground Resume | `scenePhase == .active` | No | Only after initial sync completes |
|
||||||
|
| Network Restoration | `NWPath.status == .satisfied` | No | 2.5s debounce |
|
||||||
|
| Background Refresh | System decides (~hourly) | Yes | Expiration handler cancels |
|
||||||
|
| Background Processing | Overnight (~2 AM) | Yes | Includes cleanup task |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### CloudKit Errors
|
||||||
|
|
||||||
|
| Error | Handling |
|
||||||
|
|-------|----------|
|
||||||
|
| `CKError.networkUnavailable` | Silently continue with local data |
|
||||||
|
| `CKError.networkFailure` | Silently continue with local data |
|
||||||
|
| `CKError.serviceUnavailable` | Silently continue with local data |
|
||||||
|
| `CKError.quotaExceeded` | Log and continue |
|
||||||
|
| Other errors | Log and propagate |
|
||||||
|
|
||||||
|
### Sync Errors
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum SyncError: LocalizedError {
|
||||||
|
case cloudKitUnavailable // No network - not a failure
|
||||||
|
case syncAlreadyInProgress // Another sync running - skip
|
||||||
|
case dataInconsistency(String) // Log but continue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recovery Strategy
|
||||||
|
|
||||||
|
1. **Network unavailable**: Use existing local data, sync when network returns
|
||||||
|
2. **Sync interrupted**: Progress saved per-entity, resume from last checkpoint
|
||||||
|
3. **Data corruption**: Individual records skipped, sync continues
|
||||||
|
4. **Background task expired**: Partial progress saved, next task resumes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Console Logging
|
||||||
|
|
||||||
|
The sync system uses structured logging:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private let logger = Logger(subsystem: "com.sportstime.app", category: "BackgroundSyncManager")
|
||||||
|
|
||||||
|
logger.info("Background refresh task started")
|
||||||
|
logger.warning("Background refresh task expiring - cancelling sync")
|
||||||
|
logger.error("Background refresh failed: \(error.localizedDescription)")
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter in Console.app:
|
||||||
|
- Subsystem: `com.sportstime.app`
|
||||||
|
- Categories: `BackgroundSyncManager`, `NetworkMonitor`
|
||||||
|
|
||||||
|
### Simulating Background Tasks
|
||||||
|
|
||||||
|
In Xcode debugger (LLDB):
|
||||||
|
|
||||||
|
```lldb
|
||||||
|
# Trigger refresh task
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.refresh"]
|
||||||
|
|
||||||
|
# Trigger processing task
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.db-cleanup"]
|
||||||
|
|
||||||
|
# Simulate expiration
|
||||||
|
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.sportstime.app.refresh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Sync State
|
||||||
|
|
||||||
|
Query SwiftData for sync timestamps:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let descriptor = FetchDescriptor<SyncState>()
|
||||||
|
if let syncState = try? context.fetch(descriptor).first {
|
||||||
|
print("Last stadium sync: \(syncState.lastStadiumSync ?? "never")")
|
||||||
|
print("Last team sync: \(syncState.lastTeamSync ?? "never")")
|
||||||
|
print("Last game sync: \(syncState.lastGameSync ?? "never")")
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Monitor State
|
||||||
|
|
||||||
|
```swift
|
||||||
|
print("Connected: \(NetworkMonitor.shared.isConnected)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Testing Sync
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func testSyncCancellation() async throws {
|
||||||
|
let token = BackgroundTaskCancellationToken()
|
||||||
|
|
||||||
|
// Start sync in background
|
||||||
|
let syncTask = Task {
|
||||||
|
try await syncService.syncAll(context: context, cancellationToken: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel after short delay
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
token.cancel()
|
||||||
|
|
||||||
|
// Verify partial completion
|
||||||
|
let result = try await syncTask.value
|
||||||
|
XCTAssertTrue(result.wasCancelled)
|
||||||
|
XCTAssertGreaterThan(result.totalUpdated, 0) // Some progress made
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
1. **Pagination**: Ensure all 5000+ games are fetched
|
||||||
|
2. **Cancellation**: Verify progress is saved when cancelled
|
||||||
|
3. **Network restoration**: Test airplane mode toggle triggers sync
|
||||||
|
4. **Background tasks**: Use simulator LLDB commands
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Fresh install syncs all data
|
||||||
|
- [ ] App works offline after initial sync
|
||||||
|
- [ ] Foreground resume triggers sync
|
||||||
|
- [ ] Airplane mode → disable triggers sync on restore
|
||||||
|
- [ ] Background refresh runs (check Console.app)
|
||||||
|
- [ ] Long sync can be interrupted and resumed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Info.plist Configuration
|
||||||
|
|
||||||
|
The following keys must be present in `Info.plist`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.sportstime.app.refresh</string>
|
||||||
|
<string>com.sportstime.app.db-cleanup</string>
|
||||||
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Memory
|
||||||
|
|
||||||
|
- Pagination limits memory usage to ~400 records at a time
|
||||||
|
- Records are processed and saved incrementally
|
||||||
|
- Old games (>1 year) are soft-deleted during overnight processing
|
||||||
|
|
||||||
|
### Battery
|
||||||
|
|
||||||
|
- Network monitoring is passive (no polling)
|
||||||
|
- Debounce prevents rapid sync triggers
|
||||||
|
- Heavy processing scheduled for overnight (plugged in)
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
- Incremental sync fetches only changes since last sync
|
||||||
|
- Pagination respects CloudKit rate limits
|
||||||
|
- Offline state handled gracefully
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
1. **Exponential backoff**: Retry failed syncs with increasing delays
|
||||||
|
2. **Conflict resolution**: Handle concurrent edits from multiple devices
|
||||||
|
3. **Sync priority**: Prioritize user-relevant data (upcoming games)
|
||||||
|
4. **Delta sync**: Use CloudKit subscriptions for push-based updates
|
||||||
|
5. **Metrics**: Track sync success rates, durations, and data freshness
|
||||||
Reference in New Issue
Block a user