Currently, we are using local CoreData
with CloudKit
feature, by using NSPersistentCloudKitContainer
.
Due to the problem described at https://stackoverflow.com/a/72554542/72437 , we need to enable NSPersistentHistoryTrackingKey
.
Based on https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes, we should perform persistent history purging, manually.
But, it isn't entirely clear, on how we can purge the history in a safe way, without affect the correctness of CloudKit
. We tend to run a few tests with the following setup.
@objc func storeRemoteChange(_ notification: Notification) {
// Process persistent history to merge changes from other coordinators.
historyQueue.addOperation {
self.processPersistentHistory()
}
}
/**
Process persistent history, posting any relevant transactions to the current view.
*/
private func processPersistentHistory() {
backgroundContext.performAndWait {
// Fetch history received from outside the app since the last token
let historyFetchRequest = NSPersistentHistoryTransaction.fetchRequest!
historyFetchRequest.predicate = NSPredicate(format: "author != %@", appTransactionAuthorName)
let request = NSPersistentHistoryChangeRequest.fetchHistory(after: lastHistoryToken)
request.fetchRequest = historyFetchRequest
let result = (try? backgroundContext.execute(request)) as? NSPersistentHistoryResult
guard let transactions = result?.result as? [NSPersistentHistoryTransaction] else { return }
...
// Update the history token using the last transaction.
lastHistoryToken = transactions.last!.token
// Remove history before the last history token
let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: lastHistoryToken)
do {
try backgroundContext.execute(purgeHistoryRequest)
} catch {
error_log(error)
}
}
}
Our observation is, the real device is getting wrong CloudKit
sync info. The real device is either getting duplicated data, or its data is being deleted.
Our hypothesis to this problem is
lastHistoryToken
, then purge all histories older than lastHistoryToken
.CloudKit
for syncing. There is high chance that, the CloudKit
coordinator is not yet process the removed history transactions.CloudKit
tend to sync the real device data, without necessary transaction history.We fine tune the above code, by only removing transaction history older than 2 minutes.
// Remove history older than 2 minutes.
let date = Date(timeMillis: Date.currentTimeMillis - 2*60*1000)
let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
do {
try backgroundContext.execute(purgeHistoryRequest)
} catch {
error_log(error)
}
Our observation is that
storeRemoteChange
triggering and current storeRemoteChange
is less than 2 minutes, real device will get correct CloudKit sync info.storeRemoteChange
triggering and current storeRemoteChange
is more than 2 minutes, real device will get wrong CloudKit sync info. The real device is either getting duplicated data, or its data is being deleted.Based on How to prune history right in a CoreData+CloudKit app?
The author suggest
So it is indeed safe to prune the persistent history after say seven days after it has been processed.
For 1 user, 2 devices case.
Does this mean, device B will be getting the wrong CloudKit sync info? (Seems like yes based on Test 2 observation)
If yes, what is a good way to perform persistent history purging, without affecting the correctness of CloudKit?
You can setup and run the test 2 by
CoreDataStack.swift
with https://gist.github.com/yccheok/df21f199b81b19764ffbcd4a4583c430 . It contains helper functions for Date
, and the 2 minute history purging code.(In this picture, left device is a real device, and right device is a simulator)
UPDATE: Particularly important for a CoreData + CloudKit setup
In this post from a WWDC22 Core Data Lab, an Apple Core Data framework engineer answers the question "Do I ever need to purge the persistent history tracking data?" as follows:
No. We don’t recommend it. NSPersistentCloudKitContainer uses the persistent history token to track what to sync. If you delete history the cloud sync is reset and has to upload everything from scratch. It will recover but it’s not a good customer experience. It shouldn’t normally be necessary to delete history. For example, the Apple Photos app doesn’t trim its history, so unless you’re generating massive amounts of history don’t do it.
tl;dr:
It seems that purging the persistent history after 7 days works in almost all cases.
It probably does not, if GBs of data have to be synced.
What I did:
I could reproduce the error:
If in Apple' demo app data are synced after the persistent history is purged, wrong data may be displayed. Apparently some info has been deleted that is essential for the demo app.
Below, I started testing with a clean setup:
I deleted the app from simulator and device, and cleared all CD_Post
records in the iCloud private database, zone com.apple.coredata.cloudkit.zone
, using the dashboard.
To check for info that might have been deleted unintentionally, I inserted in func processPersistentHistory()
a print statement in the guard statement that filters the persistent history for transactions:
guard let transactions = result?.result as? [NSPersistentHistoryTransaction],
!transactions.isEmpty
else {
print("**************** \(String(describing: result?.result))")
return
}
If I run the app on the simulator under Xcode, no entries were shown as expected, and the log shows now many such entries:
**************** Optional(<__NSArray0 0x105a61900>(
)
)
Apparently the persistent history contains iCloud mirroring housekeeping information that is deleted when the persistent history is purged. This indicates to me that the mirroring software needs "enough time" to finish its operation successfully, and thus only "old" history entries should be purged. But what is "old"? 7 days?
Next, on the simulator under Xcode, I installed and executed the app with immediate purging as in Test 1 of the question.
// Remove history before the last history token
let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: lastHistoryToken)
do {
try taskContext.execute(purgeHistoryRequest)
} catch {
print("\(error)")
}
On the simulator, I added an entry. This entry was shown in the dashboard.
Then, on the device under Xcode, I also installed and executed the app with immediate purging. The entry was correctly shown, i.e. the iCloud record was mirrored to the persistent store of the device, the history was processed and immediately purged, although, maybe, the the mirroring software did not have "enough time" to finish its operation successfully.
On the simulator, I added a 2nd entry. This entry was also shown in the dashboard.
However, on the device the 1st entry disappeared, i.e. the table was now empty, but both entries were still shown in the dashboard, i.e. the iCloud data were not corrupted.
I then set a breakpoint at DispatchQueue.main.async
of func processPersistentHistory()
. This breakpoint is only reached when a remote change of the persistent store is processed. To reach the breakpoint in the device, I added a 3rd entry in the simulator. Thus the breakpoint was reached in the device, and in the debugger I entered
(lldb) po taskContext.fetch(Post.fetchRequest())
▿ 3 elements
- 0 : <Post: 0x281400910> (entity: Post; id: 0xbc533cc5eb8b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p3>; data: <fault>)
- 1 : <Post: 0x281403d90> (entity: Post; id: 0xbc533cc5eb6b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p4>; data: <fault>)
- 2 : <Post: 0x281403390> (entity: Post; id: 0xbc533cc5eb4b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p5>; data: <fault>)
This indicates to me that the persistent store in the device has correct data, and only the displayed table is wrong.
Next I investigated func update
in the MainViewController
. This function is called from func didFindRelevantTransactions
, which is called when history is processed, and relevant transactions are posted. During my tests, transactions.count
is always <= 10, so the transactions are processed in the block transactions.forEach
.
I tried to find out what NSManagedObjectContext.mergeChanges
does. Thus I modified the code as
transactions.forEach { transaction in
guard let userInfo = transaction.objectIDNotification().userInfo else { return }
let viewContext = dataProvider.persistentContainer.viewContext
print("BEFORE: \(dataProvider.fetchedResultsController.fetchedObjects!)")
print("================ mergeChanges: userInfo: \(userInfo)")
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [viewContext])
print("AFTER: \(dataProvider.fetchedResultsController.fetchedObjects!)")
}
To see, what happens to the viewContext
, I implemented
@objc func managedObjectContextObjectsDidChange(notification: NSNotification) {
guard let userInfo = notification.userInfo else { return }
print(#function, userInfo)
}
and to see how this influences the fetchedResultsController
, I implemented also
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
didChange anObject: Any,
at indexPath: IndexPath?,
for type: NSFetchedResultsChangeType,
newIndexPath: IndexPath?) {
print("**************** ", #function, "\(type) ", anObject)
}
To keep the logs relatively short, I deleted in the dashboard all CD_Post
entries except the 1st one, and deleted the app from the simulator ans the device.
I then run, under Xcode, the app on the simulator and the device. Both show the 1st entry.
I then entered another entry in the simulator. As unfortunately expected, the table on the device was cleared. Here is the log of the device:
BEFORE: [<Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {
attachments = (
);
content = nil;
location = nil;
tags = (
);
title = "Untitled 3:40:24 PM";
}), <Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: <fault>)]
================ mergeChanges: userInfo: [AnyHashable("deleted_objectIDs"): {(
0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>,
0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>
)}]
managedObjectContextObjectsDidChange(notification:) [AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a8100>, AnyHashable("deleted"): {(
<Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: {
attachments = (
);
content = nil;
location = nil;
tags = (
);
title = nil;
}),
<Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {
attachments = (
);
content = nil;
location = nil;
tags = (
);
title = "Untitled 3:40:24 PM";
})
)}, AnyHashable("NSObjectsChangedByMergeChangesKey"): {(
)}]
**************** controller(_:didChange:at:for:newIndexPath:) NSFetchedResultsChangeType(rawValue: 2) <Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: {
attachments = (
);
content = nil;
location = nil;
tags = (
);
title = nil;
})
**************** controller(_:didChange:at:for:newIndexPath:) NSFetchedResultsChangeType(rawValue: 2) <Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {
attachments = (
);
content = nil;
location = nil;
tags = (
);
title = "Untitled 3:40:24 PM";
})
managedObjectContextObjectsDidChange(notification:) [AnyHashable("updated"): {(
<NSCKRecordZoneMetadata: 0x2802ce9e0> (entity: NSCKRecordZoneMetadata; id: 0x9aac7c6d193c77d2 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordZoneMetadata/p1>; data: {
ckOwnerName = "__defaultOwner__";
ckRecordZoneName = "com.apple.coredata.cloudkit.zone";
currentChangeToken = "<CKServerChangeToken: 0x2823fcdc0; data=AQAAAAAAAACQf/////////+gT9nZvOBLv7hsIaI3NVdg>";
database = "0x9aac7c6d193c77e2 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKDatabaseMetadata/p1>";
encodedShareData = nil;
hasRecordZoneNum = 1;
hasSubscriptionNum = 0;
lastFetchDate = "2022-06-15 13:55:25 +0000";
mirroredRelationships = "<relationship fault: 0x2821a3c60 'mirroredRelationships'>";
needsImport = 0;
needsRecoveryFromIdentityLoss = 0;
needsRecoveryFromUserPurge = 0;
needsRecoveryFromZoneDelete = 0;
needsShareDelete = 0;
needsShareUpdate = 0;
queries = "<relationship fault: 0x2821a2560 'queries'>";
records = (
);
supportsAtomicChanges = 1;
supportsFetchChanges = 1;
supportsRecordSharing = 1;
supportsZoneSharing = 1;
})
)}, AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a1900>, AnyHashable("deleted"): {(
<NSCKRecordMetadata: 0x2802ce850> (entity: NSCKRecordMetadata; id: 0x9aac7c6d193c7762 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordMetadata/p1>; data: {
ckRecordName = "3FB952E5-6B30-472E-BC6E-0116FA507B88";
ckRecordSystemFields = nil;
ckShare = nil;
encodedRecord = "{length = 50, bytes = 0x6276786e f7090000 52070000 e0116270 ... 61726368 69000ee0 }";
entityId = 3;
entityPK = 1;
lastExportedTransactionNumber = nil;
moveReceipts = (
);
needsCloudDelete = 0;
needsLocalDelete = 0;
needsUpload = 0;
pendingExportChangeTypeNumber = nil;
pendingExportTransactionNumber = nil;
recordZone = nil;
}),
<NSCKRecordMetadata: 0x2802cdcc0> (entity: NSCKRecordMetadata; id: 0x9aac7c6d195c7762 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordMetadata/p2>; data: {
ckRecordName = "0919480D-16CB-49F9-8351-9471371040AC";
ckRecordSystemFields = nil;
ckShare = nil;
encodedRecord = "{length = 50, bytes = 0x6276786e f7090000 52070000 e0116270 ... 61726368 69000ee0 }";
entityId = 3;
entityPK = 2;
lastExportedTransactionNumber = nil;
moveReceipts = (
);
needsCloudDelete = 0;
needsLocalDelete = 0;
needsUpload = 0;
pendingExportChangeTypeNumber = nil;
pendingExportTransactionNumber = nil;
recordZone = nil;
})
)}]
managedObjectContextObjectsDidChange(notification:) [AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a3060>, AnyHashable("invalidatedAll"): <__NSArrayM 0x282f75830>(
)
]
AFTER: []
This indicates to me:
NSManagedObjectContext.mergeChanges
, the table was correct, i.e. it contained both posts p1 & p2.viewContext
, both posts were deleted (AnyHashable("deleted")
).fetchedResultsController
responded by deleting both posts also (NSFetchedResultsChangeType(rawValue: 2)
).fetchedResultsController
has no objects, and thus the table is empty.As a final check, I out commented in func processPersistentHistory()
the code that purges the history, and as expected, the table was displayed correctly, also when I entered another entry in the simulator.
What are the conclusions?
Further hints to reproduce the tests (this may help others who try the same):
As suggested, I downloaded Apple's demo app and the core data stack modified by you.
It did compile for a simulator, but for the device I had to set 3 additional settings in the Signing & Capabilities tab of the target:
com.<your company>.CoreDataCloudKitDemo
.iCloud.com.<your company>.CoreDataCloudKitDemo
.Then, I could run the app on the simulator and the device.
I verified in the CloudKit Console that in the Private Database, zone com.apple.coredata.cloudkit.zone
there are no records of type CD_Post. Since data are not shared, the iCloud Sharing database is not used.