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 - Info.plist configuration requirements 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