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>
25 KiB
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
SportsTime uses an offline-first architecture where:
- Bundled JSON provides initial data on first launch
- SwiftData stores all canonical data locally
- CloudKit (public database) is the authoritative source for schedule updates
- 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.
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
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:
- Sports - Must exist before teams reference them
- Stadiums - Must exist before teams reference them
- Teams - Must exist before games reference them
- Games - References teams and stadiums
- League Structures - References teams
- Team Aliases - References teams
- Stadium Aliases - References stadiums
Per-Entity Progress
Progress is saved after each entity type completes:
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:
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:
// In SportsTimeApp.init()
BackgroundSyncManager.shared.registerTasks()
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:
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
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:
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:
// 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
protocol SyncCancellationToken: Sendable {
var isCancelled: Bool { get }
}
Implementation
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:
- Between entity types - After completing stadiums, check before starting teams
- Between pagination pages - After fetching 400 records, check before fetching the next page
- 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
enum SyncError: LocalizedError {
case cloudKitUnavailable // No network - not a failure
case syncAlreadyInProgress // Another sync running - skip
case dataInconsistency(String) // Log but continue
}
Recovery Strategy
- Network unavailable: Use existing local data, sync when network returns
- Sync interrupted: Progress saved per-entity, resume from last checkpoint
- Data corruption: Individual records skipped, sync continues
- Background task expired: Partial progress saved, next task resumes
Debugging
Console Logging
The sync system uses structured logging:
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):
# 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:
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
print("Connected: \(NetworkMonitor.shared.isConnected)")
Testing
Unit Testing Sync
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
- Pagination: Ensure all 5000+ games are fetched
- Cancellation: Verify progress is saved when cancelled
- Network restoration: Test airplane mode toggle triggers sync
- 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:
<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
- Exponential backoff: Retry failed syncs with increasing delays
- Conflict resolution: Handle concurrent edits from multiple devices
- Sync priority: Prioritize user-relevant data (upcoming games)
- Delta sync: Use CloudKit subscriptions for push-based updates
- Metrics: Track sync success rates, durations, and data freshness