CloudKit: Delete local objects based on CKQueryNotification
If CloudKit doesn't give you any indication of the type of record that was deleted, it's kind of a pain to deal with. You can't delete objects in Core Data without knowing the entity type, so if CloudKit doesn't give you any clues then you need to check every entity that could have the recordId
.
The delete process is the same as usual with Core Data. Do a fetch request with a predicate of something like `recordId = %@" to find a matching object. If you find one, delete it. Except that you have to repeat this for each potential entity.
One approach that might help is to store the recordId
in a new, separate entity. Create a new entity called something obvious like CKRecordInfo
and keep the recordId
there. Every other entity that has CloudKit info would have a one-to-one relationship to this entity. With this setup you'd fetch an instance of the new CKRecordInfo
entity, and delete whichever object it's related to.
At the same time though-- I haven't used CloudKit, and it's more than a little surprising that it would give you just the recordId
with no information about the record type. Getting the info from the notification would be ideal, if it's possible.
CloudKit public database deleted record
Detecting changes in the public database is also handled via subscriptions, but there are different types of subscriptions for different aspects of cloudkit. As noted at https://developer.apple.com/library/content/qa/qa1917/_index.html (emphasis added)
Note: The initializers for creating a CKSubscription object with a
subscriptionID are deprecated, so use CKQuerySubscription,
CKRecordZoneSubscription, or CKDatabaseSubscription on iOS 10.0+,
macOS 10.12+, and tvOS 10.0+. Be aware that CKQuerySubscription is not
supported in the shared database, and CKDatabaseSubscription currently
only tracks the changes from custom zones in the private and shared
database.
So, you'll need to use a CKQuerySubscription
to detect changes in the public database. With a CKQuerySubscription
you will specify a record type, optional search parameters (via NSPredicate
) and specify if the subscription should fire on creation, update, and/or deletion of the record.
The app will then receive a push notification when the trigger condition is met and is responsible to update the user's local data store as appropriate.
Cloudkit CKRecordZoneNotification how to know whether Add or modify happened
There is no info in the recordChangedBlock
that tells you whether it was added or changed. Keep in mind, even if it did, you still have to check whether the record exists or not in the local store. A record can be added into CloudKit and then changed several times all while your app isn't running. When your app finally runs, it will get only the last change notification. But the record doesn't exist in your local cache yet. So you must always see if you have the record locally or not and add/update accordingly.
With deletion, all you get is the CloudKit record id. Nothing else. What I do is ensure the CloudKit record id is based on the local key. This way I can easily find and remove the local record when the data is removed from Cloudkit. It also means that the local copy of the CloudKit data on all of the user's devices ends up with the same keys.
Sync an iOS app that uses core data to cloud
CloudKit
and CoreData
do not automatically work together seamlessly, so you will need to write that logic yourself.
There are different types of iCloud storage options, one or two of which do seamlessly integrate with CoreData
, but CloudKit
is not one of them, and CloudKit
is what you will need to use if you have aspirations of enabling your users to share data with others.
AKA: You will need to do the heavy lifting yourself, but if you use good design practices you can do the work once without having to rewrite much of your existing code.
So, here's something similar to what I did in one of my projects that used both frameworks:
Create Core Data object model and
NSManagedObject
subclasses like you almost certainly already have.Turn on CloudKit in Xcode project capabilities and log in to CloudKit Dashboard
Use CloudKit Dashboard to design your record model modeled after your Core Data entity model
(Back in Xcode) Create methods somewhere (most conveniently as extensions to your
NSManagedObject
subclasses) that know how to create the given Core Data object from aCKRecord
, and to create aCKRecord
from a Core Data object.Create one or more Swift classes dedicated to dealing with your CloudKit records and synching them with Core Data. This class(es) would be responsible for performing all CloudKit operations at a high level, including fetching, adding, deleting, modifying, etc. You can design this public API however you want (it should be customized to your needs), but this class would most likely use the methods you created in the previous step to convert to and from Core Data types.
With this approach, your CloudKit specializing class (we'll call it CloudBrain
) does all of the heavy lifting, and if you want you can make it do it all behind the scenes. For example, you could define another class, SyncBrain
, that would automatically listen for changes in the Core Data managed object context, and call corresponding methods on CloudBrain
to ensure all changes are being kept in sync with iCloud. It would also need to do the reverse, listening for changes in iCloud and applying them to Core Data. This will of course require fetching changes initially from CloudBrain
and you'll also want to look into CKSubscription
for real-time updates.
The beauty of this approach is that if you set up all of that correctly, you can keep all of your other code the same, because every time your other classes interact with Core Data, SyncBrain
automatically ensures that all changes in Core Data are reflected in iCloud and vice versa.
As for sharing with other users, this feature is new in iOS 10 and it does not appear as though Apple has yet updated the CloudKit Quick Start. You should therefore watch What's New with CloudKit from WWDC this year.
Important Note: When designing your record model in CloudKit Dashboard, be sure to follow the iCloud Design Guide and not have parent record types with fields holding arrays of a child record type. This is not great performance. Instead, define the child record type to have a single CKReference
field that points to its parent. That way if you need the children for a parent, you can just create a query that requests all objects with their parent set to the parent you want (as opposed to having to wait for all of the children to download when all you wanted was the parent).
Here are some WWDC sessions. Older sessions still contain very useful information but some of it is out of date.
- 2014 – Introducing CloudKit (A must-watch to get the concepts correct)
- 2014 – Advanced CloudKit
- 2015 – What's New in CloudKit
- 2015 – CloudKit Tips and Tricks
- 2016 – What's New with CloudKit
- 2016 – CloudKit Best Practices
What is the right way to perform persistent history purging, without affecting the correctness of CloudKit?
UPDATE: Particularly important for a CoreData + ClouKit 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:
- Before
NSManagedObjectContext.mergeChanges
, the table was correct, i.e. it contained both posts p1 & p2. - Merging was done again with both posts.
- In the
viewContext
, both posts were deleted (AnyHashable("deleted")
). - The
fetchedResultsController
responded by deleting both posts also (NSFetchedResultsChangeType(rawValue: 2)
). - Eventually it is logged that the
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?
- On both persistent stores (simulator & device), and in iCloud, all data were always correct.
- Merging of remote store changes to a context fails, if the mirroring software does not have enough time to process its entries in the persistent history.
- How long this takes depends probably on the amount of data that has to be synced. My experience is that some kb take some seconds, but this depends of course on many parameters. But if so, 7 days correspond to some GB to sync, which is rather unusual. In this respect, purging the persistent history after 7 days seems to be a good compromise between memory consumption and correct app operation.
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:
- Set the development team
- Set the bundle identifier to a reasonable value, e.g.
com.<your company>.CoreDataCloudKitDemo
. - Select the right iCloud container, e.g.
iCloud.com.<your company>.CoreDataCloudKitDemo
. - Additionally I had to ensure that the simulator and the device were logged in to the same iCloud account. Note that for the simulator, one has to re-log in about once a day. Mostly one is reminded to do so, but sometimes not.
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.
Related Topics
Playing Multiple Wav Out Multiple Channels Avaudioengine
Swift: Draw a Semi-Sphere in Mkmapview
Swiftui Conditional View Transitions Are Not Working
Swift: Change Speed of a Moveto Skaction
Cannot Increment Beyond Endindex
How to Bend a Rectangle in Sprite Kit
How to Customise UIsearchcontroller
Why I Can't Access My 3Rd Level Coredata Data in Swift
Swift- Mkmapkit View Only One City
Why My Arguments Are Being Blocked When Running a Shell Command
Restrictions Around Protocols and Generics in Swift
Query Value Between Two Other Values in Firebase
Nssavepannel - How to Restrict User to Only Save One One Set Directory
Bleed Through from Nsbutton Checkbox on Non-Transparent Nspopover
Generic Return Type Based on Class