mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
Resume CloudKit sync from persisted state instead of full re-fetch
Previously CKSyncEngine was created with nil state serialization on every launch, replaying the entire zone change history (hundreds of merge/delete log lines and re-downloads each start). Now the saved state is loaded so launches only fetch changes since the last session. To make incremental sync safe: - Clear persisted state once when the record schema version increases, so records previously skipped as unsupported get re-delivered after an app update. - Handle remote zone deletion in fetchedDatabaseChanges by recreating the zone and re-uploading local data, instead of leaving sync dead. Account change and manual Refresh Sync still clear the state and force a full fetch as before.
This commit is contained in:
@@ -22,7 +22,8 @@ final class CloudKitRecordMapper {
|
||||
|
||||
/// Current schema version for CloudKit records.
|
||||
/// Increment this when record structure changes.
|
||||
private let currentSchemaVersion: Int64 = 2
|
||||
static let currentSchemaVersion: Int64 = 2
|
||||
private var currentSchemaVersion: Int64 { Self.currentSchemaVersion }
|
||||
|
||||
init(zone: CKRecordZone) {
|
||||
self.zone = zone
|
||||
|
||||
@@ -123,6 +123,9 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
|
||||
/// UserDefaults key for CloudKit sync state.
|
||||
private let syncStateKey = "cloudKitSyncState"
|
||||
|
||||
/// UserDefaults key for the record schema version this app last synced with.
|
||||
private let lastSchemaVersionKey = "cloudKitLastSchemaVersion"
|
||||
|
||||
/// UserDefaults key for persisting pending save record names (crash recovery).
|
||||
private let pendingSaveRecordNamesKey = "cloudKitPendingSaveRecordNames"
|
||||
@@ -329,14 +332,23 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
let zone = await zoneManager.getZone()
|
||||
self.recordMapper = CloudKitRecordMapper(zone: zone)
|
||||
|
||||
// Initialize CKSyncEngine with nil state to always do a fresh fetch on launch.
|
||||
// This ensures we never miss changes due to stale tokens, at the cost of ~2s for typical record counts.
|
||||
// Sync state is still saved (see stateUpdate handler) so CKSyncEngine can use it within a session.
|
||||
LoggingService.shared.logCloudKit("Creating CKSyncEngine with fresh state (nil) for reliable sync")
|
||||
// Records written by a newer app version fail to parse and are only
|
||||
// re-delivered on a full fetch, so a schema upgrade must invalidate the
|
||||
// persisted state once to pick them up.
|
||||
invalidateSyncStateAfterSchemaUpgradeIfNeeded()
|
||||
|
||||
// Resume from persisted sync state so launches fetch only changes since
|
||||
// the last session instead of replaying the entire zone change history.
|
||||
// Starts with nil (full fetch) on first sync, account change, manual
|
||||
// refresh, or schema upgrade.
|
||||
let savedState = loadSyncState()
|
||||
LoggingService.shared.logCloudKit(savedState != nil
|
||||
? "Creating CKSyncEngine with persisted state (incremental fetch)"
|
||||
: "Creating CKSyncEngine with fresh state (full fetch)")
|
||||
|
||||
let config = CKSyncEngine.Configuration(
|
||||
database: database,
|
||||
stateSerialization: nil,
|
||||
stateSerialization: savedState,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
@@ -431,6 +443,22 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
LoggingService.shared.logCloudKit("Cleared sync state for account change")
|
||||
}
|
||||
|
||||
/// Clears persisted sync state after an app update that raised the record
|
||||
/// schema version. Records from newer schema versions are rejected by the
|
||||
/// mapper and are not re-delivered by an incremental fetch, so the first
|
||||
/// launch that understands them must do one full re-fetch.
|
||||
private func invalidateSyncStateAfterSchemaUpgradeIfNeeded() {
|
||||
let storedVersion = Int64(UserDefaults.standard.integer(forKey: lastSchemaVersionKey))
|
||||
let currentVersion = CloudKitRecordMapper.currentSchemaVersion
|
||||
guard storedVersion < currentVersion else { return }
|
||||
|
||||
if UserDefaults.standard.data(forKey: syncStateKey) != nil {
|
||||
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
||||
LoggingService.shared.logCloudKit("Schema version upgraded (\(storedVersion) -> \(currentVersion)), cleared sync state for full re-fetch")
|
||||
}
|
||||
UserDefaults.standard.set(Int(currentVersion), forKey: lastSchemaVersionKey)
|
||||
}
|
||||
|
||||
/// Loads persisted sync state from disk.
|
||||
private func loadSyncState() -> CKSyncEngine.State.Serialization? {
|
||||
guard let data = UserDefaults.standard.data(forKey: syncStateKey) else {
|
||||
@@ -1570,7 +1598,8 @@ final class CloudKitSyncEngine: @unchecked Sendable {
|
||||
retryCount.removeAll()
|
||||
syncEngine = nil
|
||||
|
||||
// Reinitialize with nil state serialization (forces fresh fetch)
|
||||
// Reinitialize - with the persisted state cleared above, setup starts
|
||||
// from nil state serialization (forces full fetch)
|
||||
await setupSyncEngine()
|
||||
|
||||
LoggingService.shared.logCloudKit("Refresh sync completed")
|
||||
@@ -1778,6 +1807,9 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
}
|
||||
await handlePartialFailures(changes)
|
||||
|
||||
case .fetchedDatabaseChanges(let changes):
|
||||
await handleFetchedDatabaseChanges(changes)
|
||||
|
||||
default:
|
||||
await MainActor.run {
|
||||
switch event {
|
||||
@@ -1793,9 +1825,6 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
await self?.setupSyncEngine()
|
||||
}
|
||||
|
||||
case .fetchedDatabaseChanges:
|
||||
LoggingService.shared.logCloudKit("Fetched database changes")
|
||||
|
||||
case .willFetchChanges:
|
||||
LoggingService.shared.logCloudKit("CKSyncEngine will fetch changes (auto-triggered)")
|
||||
isReceivingChanges = true
|
||||
@@ -1990,6 +2019,29 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
|
||||
retryCount.removeValue(forKey: recordName)
|
||||
}
|
||||
|
||||
/// Handle fetched database-level changes (zone creations/deletions).
|
||||
/// Now that the engine resumes from persisted state, a remote deletion of our
|
||||
/// zone (reset from another device, iCloud data purge, encrypted data reset)
|
||||
/// would leave sync silently dead - recreate the zone and re-seed it from
|
||||
/// local data.
|
||||
private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) async {
|
||||
LoggingService.shared.logCloudKit("Fetched database changes")
|
||||
|
||||
guard changes.deletions.contains(where: { $0.zoneID.zoneName == RecordType.zoneName }) else {
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.logCloudKit("Zone '\(RecordType.zoneName)' was deleted remotely - recreating and re-uploading local data")
|
||||
UserDefaults.standard.removeObject(forKey: syncStateKey)
|
||||
|
||||
do {
|
||||
try await zoneManager.createZoneIfNeeded()
|
||||
await performInitialUpload()
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle fetched changes from CloudKit.
|
||||
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) async {
|
||||
guard let dataManager else { return }
|
||||
|
||||
Reference in New Issue
Block a user