Files
Sportstime/docs/SYNC_RELIABILITY.md
Trey t d377d03b10 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>
2026-01-13 19:24:04 -06:00

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:

  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.

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:

  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:

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:

  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

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:

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

  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:

<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