From 76a6958f5ec244bdb8cb9d4c0a925ba0b3eb6d7e Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 19:28:42 -0600 Subject: [PATCH] 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 --- docs/SYNC_RELIABILITY.md | 759 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 docs/SYNC_RELIABILITY.md diff --git a/docs/SYNC_RELIABILITY.md b/docs/SYNC_RELIABILITY.md new file mode 100644 index 0000000..c1dc41e --- /dev/null +++ b/docs/SYNC_RELIABILITY.md @@ -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() +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 +BGTaskSchedulerPermittedIdentifiers + + com.sportstime.app.refresh + com.sportstime.app.db-cleanup + +UIBackgroundModes + + fetch + processing + +``` + +--- + +## 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