# 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