1 contributor
279 lines | 7.785kb

CloudKit Sync

Mecanismul de replicare a datelor către iCloud.

Arhitectură

Components

  1. NSPersistentCloudKitContainer: gestionează Core Data + CloudKit automat
  2. CloudDeviceSettingsStore: wrapper pe NSManagedObjectContext
  3. Core Data Model: CKModel.xcdatamodeld (curent: USB_Meter 2)
  4. CloudKit Container: iCloud.ro.xdev.USB-Meter

Model versioning

USB_Meter 1 (original)
  ↓
USB_Meter 2 (fix: removed uniqueness constraints)
  ↓
USB_Meter 3? (future)

Curent: USB_Meter 2 (schema v20)

Data Schema

DeviceSettings entity (Core Data)

Field Type Notes
id UUID Primary key
macAddress String Optional (for migration)
meterName String Chosen by user
tc66TemperatureUnit String "celsius" / "fahrenheit"
createdAt Date Immutable
updatedAt Date Last sync timestamp
connectionMetadata JSON blob Device, timestamp, expiry
discoveryMetadata JSON blob Last seen, seen by
cloudKitRecordID String Reference toward CloudKit

Invarianţi

  • MUST: macAddress nu are uniquenessConstraint (not CloudKit safe)
  • MUST: Chiar dacă macAddress optional, duplicate entries ar trebui merged
  • MUST: meterName e unic per meter (no machine-generated names)
  • MUST: updatedAt se schimbă la fiecare sync
  • SHOULD: connectionMetadata.expiresAt = now() + 24h

Sync Lifecycle

Push (local → CloudKit)

User changes meterName
  ↓
AppData calls cloudStore.upsertDeviceSettings(...)
  ↓
NSManagedObjectContext saves
  ↓
NSPersistentCloudKitContainer auto-uploads to CloudKit
  ↓
CloudKit replica updated
  ↓
Other devices see change via NSPersistentCloudKitContainerEventChangeNotification
  • MUST: Core Data save trebuie să se afle pe main thread
  • MUST: CloudKit sync e asincronă (fire-and-forget)
  • SHOULD: Notify UI după local save (don't wait for CloudKit)
  • MAY: Log upload errors

Pull (CloudKit → local)

iCloud change appears
  ↓
NSPersistentCloudKitContainer notifies
  ↓
AppData observes NSPersistentCloudKitContainerEventChangeNotification
  ↓
Merge remote change with local state
  ↓
Core Data context updated
  ↓
UI refreshes
  • MUST: Merge strategy = "last write wins" (basate pe timestamp)
  • MUST: Conflict resolution e automat (NSMergeByPropertyObjectTrumpMergePolicy)
  • SHOULD: Loghează merge pentru debugging
  • MAY: Notifică user dacă conflict major

Conflict scenarios

Scenario 1: Same meter renamed on two devices simultaneously Device A: "Kitchen Meter" → "Main Meter" (10:00:00) Device B: "Kitchen Meter" → "Lab Meter" (10:00:05) Resolution: Last timestamp wins → "Lab Meter" (Device B @ 10:00:05)

Scenario 2: Duplicate entries cu aceeași MAC Device A: macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1" Device B (after restore): macAddress = "AA:BB:CC:DD:EE:FF", meterName = "Meter 1" Resolution: Merge duplicate entries, keep one record (last write wins)

Discovery Throttling

Logică

func recordDiscovery(for macAddress: String) {
    let lastSeen = discoveryMetadata[macAddress]?.lastSeen
    let now = Date()
    
    if now.timeIntervalSince(lastSeen ?? .distantPast) >= 120 {
        // OK: permit sync
        cloudStore.recordDiscovery(macAddress, discoveredAt: now)
    }
    // else: skip (under throttle window)
}
  • MUST: Max 1 discovery record per device per 120s
  • SHOULD: Throttle pe app level (nu pe CloudKit)
  • MUST: Reseta timer dacă device disconnect-ează
  • REASON: Prevent CloudKit thrashing din repeat BT advertisements

Impact

  • BT scan at 0s, 30s, 60s, 90s, 120s, ... (periodc)
  • Doar descoperirile la 0s, 120s, 240s, ... sunt syncate la CloudKit
  • Alte descoperiri sunt cached local

Rebuild logic

Background

Issue (March 2025): - Old uniquenessConstraint pe macAddress = incompatibil cu CloudKit - Old rebuild logic: delete ALL + recreate = delete storm în CloudKit - Old naming: meterName = platform model ("iPad") = irelevant pe CloudKit

Fix

Schema migration: USB_Meter 1 → USB_Meter 2 - Removed uniquenessConstraint - Made macAddress optional - Changed device name = hostname (all platforms)

Rebuild refactoring: rebuildCanonicalStoreIfNeeded() ```swift func rebuildCanonicalStoreIfNeeded(newVersion: Int) { if cloudStoreRebuildVersion >= newVersion { return }

// Update winner in-place, delete only duplicates
let groupedByMAC = groupEntries(by: \.macAddress)
for (mac, entries) in groupedByMAC {
    guard entries.count > 1 else { continue }
    let winner = entries.max(by: \.updatedAt)
    let losers = entries.filter { $0.id != winner.id }

    // Delete losers only, keep winner
    for loser in losers {
        context.delete(loser)
    }
}

cloudStoreRebuildVersion = newVersion
try? context.save()

} ```

  • MUST: Update winner în-place (nu delete+recreate)
  • MUST: Delete doar duplicate entries (nu pe toti)
  • MUST: Set cloudStoreRebuildVersion = 3 după fix
  • REASON: Prevent delete storms în CloudKit

Version history

  • v1: Original schema (cu uniqueness constraint)
  • v2: First rebuild (delete all + recreate) — DEPRECATED
  • v3: Correct rebuild (update in-place + delete duplicates only)

Legacy data migration

NSUbiquitousKeyValueStore (deprecated)

// Legacy stores
let meterNames = NSUbiquitousKeyValueStore.default.dictionary(forKey: "MeterNames")
// → {macAddress → meterName}

let tempUnits = NSUbiquitousKeyValueStore.default.dictionary(forKey: "TC66TemperatureUnits")
// → {macAddress → unit}

Migration path

  1. Read from KV store
  2. For each MAC address: swift cloudStore.upsertDeviceSettings( macAddress: mac, meterName: meterNames[mac], tc66TemperatureUnit: tempUnits[mac] )
  3. Mark as migrated în defaults

  • MUST: Menţine KV store pentru backward compat
  • SHOULD: Migrate pe startup dacă not migrated
  • MAY: Drop KV store la next major version

Testing

Unit tests

test_cloudStoreSaves_ToNSManagedObjectContext()
test_upsertDeviceSettings_CreatesOrUpdates()
test_discoveryThrottling_RespectsTiming()
test_conflictResolution_LastWriteWins()
test_rebuildCanonicalStore_UpdatesWinner_DeletesDuplicates()
test_macAddressNoDuplicates_AfterRebuild()

Integration tests

  • [ ] Multiple devices sync settings via CloudKit
  • [ ] Conflict detected and resolved
  • [ ] Rebuild v3 migrates old data correctly
  • [ ] Discovery throttling prevents CloudKit thrashing
  • [ ] Legacy KV store data migrates to Core Data

Error handling

Network errors

Error: Network unavailable
→ Fail fast (don't retry immediately)
→ Queue pending changes in Core Data
→ Retry at next network change (observe URLSession events)

CloudKit quota errors

Error: Quote exceeded (too many records)
→ Log error
→ Notify user: "iCloud storage full"
→ Option: Archive old charge records

Merge conflicts

Error: Conflict detected
→ Auto-resolve via "last write wins"
→ Log both versions for debugging
→ Notify user if significant loss

Dependencies

  • NSPersistentCloudKitContainer: from CloudKit framework
  • CloudDeviceSettingsStore: wrapper in AppData.swift
  • CKModel.xcdatamodeld: Core Data schema
  • NSUbiquitousKeyValueStore: legacy (deprecated)

References