Files
Sportstime/docs/SYNC_RELIABILITY.md
Trey t 76a6958f5e 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>
2026-01-13 19:28:42 -06:00

760 lines
25 KiB
Markdown

# 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