cloudKit: CKFetchRecordChangesOperation in public database - ios

I building a iOS app using cloudKit. I'm trying to make a batch fetch of the data in cloudKit getting the deltas between the device and cloudKit but it seems like CKFetchRecordChangesOperation doesn't work in public database. Does my only option option is CKQuery to fetch my data ? for example:
CKContainer *container = [CKContainer containerWithIdentifier:containerID];
CKDatabase *publicDatabase = [container publicCloudDatabase];
CKQuery *query = [[CKQuery alloc] initWithRecordType:recordType
predicate:[NSPredicate predicateWithFormat:#"TRUEPREDICATE"]];
CKQueryOperation *queryOp = [[CKQueryOperation alloc] initWithQuery:query];
queryOp.desiredKeys = #[#"record.recordID.recordName"];
queryOp.recordFetchedBlock = ^(CKRecord *record)
{
// do something...
};
queryOp.queryCompletionBlock = ^(CKQueryCursor *cursor, NSError *error)
{
// do something else...
};
queryOp.resultsLimit = CKQueryOperationMaximumResults;
[publicDatabase addOperation:queryOp];
I'll really appreciate your help.

The apple documentation for CKFetchRecordChangesOperation states:
recordZoneID : The zone containing the records you want to fetch. The zone can be a
custom zone. Syncing the default zone is not supported.
This means that it will not work on the public database since than only supports the default zone.
The correct way to achieve the same functionality would be by creating subscriptions for the data you need and retrieve that data using the CKFetchNotificationChangesOperation. Of course you could also just execute some CKQuery commands, but then you would probably often fetch data or execute queries that you don't need.

Related

Deleting a CKRecord is really confusing

So I'm having a really weird problem, I suppose I either dont understand how CloudKit works under the hood or I encountered a bug in CloudKit.
So, the issue looks like this:
App initial state:
I have 5 "Package" records, lets call them A, B, C, D, E.
User action
The user will delete "Package" record E and at some later point in time he will press a refresh button which will fetch all current "Package" records from the cloud.
The problem
When the user presses the refresh button, the app will basically look at the existing locally stored "Package" records, and will create a CKQuery with a predicate that should fetch any other records that do not exist locally. The next step is basically calling the [database performQuery: inZoneWithID:completionHandler:] method.
The surprise shows up when I get the results, which contain the "Package" record E that the user previously deleted.
This doesnt seem to be right to me...
The steps I took to debug:
Right after deleting the "Package" record E, I created a CKFetchRecordsOperation and tried to fetch the deleted record. The result was as expected: I got a "Record not found". I'm cool here.
Thinking there might be some delays on the server side, I put a dispatch_after block and launched the same fetch operation I did in point 1 but just after 30 seconds. The result was still as expected: I got the "Record not found" error.
Performed the same test as I did in point 2 but with a delay of 100 seconds and ... surprise, the CKFetchRecordsOperation operation returned the deleted record E package. The weird thing is that somethings it will still return an error, but sometimes will just plainly return the deleted object.
And now the really weird part: This does not happen with record A, B, C and D, the single difference between all theses records are their names. This does not make any sense.
I filled a bug report and the reply I got was this:
This is correct behavior. Queries are eventually consistent so the deletes may not immediately be reflected when querying. Fetching the deleted record by ID via a CKFetchRecordsOperation should return a CKErrorUnknownItem right away.
While this is partially true, this does not seems to be the case with what I'm seeing.
Code
Deleting the record E with name DS2000330803AS, the check CKFetchRecordsOperation operation returns an error with Record not found. All good here.
CKContainer *container = [CKContainer defaultContainer];
CKDatabase *privateDB = [container privateCloudDatabase];
CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName: #"DS2000330803AS"];
CKModifyRecordsOperation *operation = [[CKModifyRecordsOperation alloc] initWithRecordsToSave: nil recordIDsToDelete: #[recordID]];
operation.database = privateDB;
[operation setModifyRecordsCompletionBlock:^(NSArray<CKRecord *> * _Nullable savedRecords,
NSArray<CKRecordID *> * _Nullable deletedRecordIDs,
NSError * _Nullable error) {
CKFetchRecordsOperation *fetchOperation = [[CKFetchRecordsOperation alloc] initWithRecordIDs:#[recordID]];
fetchOperation.database = privateDB;
[fetchOperation setPerRecordCompletionBlock:^(CKRecord * _Nullable record, CKRecordID * _Nullable recordID, NSError * _Nullable error){
NSLog(#"Error: %#", error.localizedDescription);
}];
}];
Placing a NSTimer in my VC just to test the Record deletion, this piece of code will return the deleted record:
[NSTimer scheduledTimerWithTimeInterval:100 repeats:NO block:^(NSTimer * _Nonnull timer) {
CKContainer *container = [CKContainer defaultContainer];
CKDatabase *privateDB = [container privateCloudDatabase];
CKRecordID *recordID = [[CKRecordID alloc] initWithRecordName:#"DS2000330803AS"];
CKFetchRecordsOperation *fetchOperation = [[CKFetchRecordsOperation alloc] initWithRecordIDs: #[recordID]];
fetchOperation.database = privateDB;
[fetchOperation setPerRecordCompletionBlock:^(CKRecord * _Nullable record, CKRecordID * _Nullable recordID, NSError * _Nullable error){
NSLog(#"Error: %#", error.localizedDescription);
}];
[privateDB addOperation: fetchOperation];
}];
The piece of code that fetches all the existing records by pressing a refresh button which the user can press at any time. I simplified this code a bit to just expose the problem, basically the performQuery returns the DS2000330803AS record, and for the sake of testing my sanity, I'm adding a CKFetchRecordsOperation to fetch the record again, which of course does return it without any issues.
CKContainer *container = [CKContainer defaultContainer];
CKDatabase *privateDB = [container privateCloudDatabase];
NSPredicate *predicate = [NSPredicate predicateWithValue: YES];
CKQuery *query = [[CKQuery alloc] initWithRecordType:#"Package" predicate:predicate];
[privateDB performQuery:query
inZoneWithID:nil completionHandler:^(NSArray<CKRecord *> * _Nullable results, NSError * _Nullable error) {
[results enumerateObjectsUsingBlock:^(CKRecord * _Nonnull record, NSUInteger idx, BOOL * _Nonnull stop) {
NSLog(#"Record ID: %#", record.recordID);
CKFetchRecordsOperation *fetchOperation = [[CKFetchRecordsOperation alloc] initWithRecordIDs: #[record.recordID]];
fetchOperation.database = privateDB;
[fetchOperation setPerRecordCompletionBlock:^(CKRecord * _Nullable record, CKRecordID * _Nullable recordID, NSError * _Nullable error){
NSLog(#"Error: %#", error.localizedDescription);
}];
[privateDB addOperation: fetchOperation];
}];
}];
Other notes: I removed and commented pretty much everything related to CloudKit and the above code is the only one that interacts with CloudKit. I'm testing with a single device at the moment.
I know the CKQuery can have a better NSPredicate, but now I try to understand why I have this issue.
P.s. When I added the first implementation of CloudKit to my app, I tried to keep it as simple as possible, without any fancy syncing stuff. It worked just fine for a year, then I started getting reports from my users that they cannot delete some records in production.
Any hints guys on how I should further debug this?
Thank you!
I think you are mixing up record Type and record Name(String of CKRecordID). Name is assigned by CloudKit(Typically) and type is set by you. I would bet it was auto assigned but I would have to see how the record was saved. It would be telling to see a screenshot of your CloudKit Dashboard.
In your block of code in
1) you are trying to delete the record name of some record using the record type. That is why you get the error "Record not found"
2) Same as you are still using Record Type and not record name
3) Fetches the record because it is actually using the assigned record.recordID.
This is my gut on the situation. As far as deleting and refreshing please see my answer on stitching records to keep UI and database in sync.

CloudKit Fetch all User records

I would like to fetch all the users that are working in the same public default container for my app.
Is it possible to fetch all user IDs?
When I try to do this:
NSPredicate *predicate = [NSPredicate predicateWithFormat:#"TRUEPREDICATE"]; // to search for all the records
CKQuery *query = [[CKQuery alloc] initWithRecordType:#"Users" predicate:predicate];
[_publicDB performQuery:query inZoneWithID:nil completionHandler:^(NSArray *results, NSError *error) {
if (error) {
// Error handling for failed fetch from public database
NSLog(#"Error:%#", error);
}
else {
// Display the fetched records
NSLog(#"Users: %#", results);
}
}];
I get an error like this:
Error:<CKError 0x7f9c6b7331f0: "Permission Failure" (10/2007); server message = "Can't query system types"; uuid = 9CBA8EB0-D9DC-46B2-BDF4-10C036599642; container ID = "iCloud.com.xxx.xxx.MyApp">
Do you know how can I achieve this? I want to know from a client perspective (iOS) what are the other users that use my app.
The Users recordType is a special recordType which you cannot query. If you want to perform this sort of queries, then you should create your own recordType and insert a record for every user.

CloudKit fetchRecordWithID error: "Fetching asset failed"

I am trying to fetch a record with CloudKit and it fails with the following error: "Fetching asset failed" I confirmed (via the CloudKit Dashboard) that the record exists in my public database and the default zone and the default container (not a custom container). Here is my code:
CKContainer *container = [CKContainer defaultContainer];
CKDatabase *publicDatabase = [container publicCloudDatabase];
CKRecordID *artworkRecordID = [[CKRecordID alloc] initWithRecordName:#"1C0DCC08-71D3-4C47-A417-DB92D2EECB67"];
[publicDatabase fetchRecordWithID:artworkRecordID completionHandler:^(CKRecord *artworkRecord, NSError *error) {
if (error) {
// Error handling for failed fetch from public database
}
else {
// Display the fetched record
}
}];
I had a user get this because they weren’t signed in to iCloud in their iPhone Settings.
As you can see in your screenshot the error code is 4 which is a network error
See xcdoc://?url=developer.apple.com/library/ios/documentation/CloudKit/Reference/CloudKit_constants/index.html#//apple_ref/c/tdef/CKErrorCode
Try switching to 3G or WiFi to see if there is different behavior.
If you go to your app settings, is mobile data enabled?
Can you run the code from the simulator?

Modify data with CloudKit

I have data I'd like to modify in CloudKit. I've found this question (Saving Modified Data in CloudKit) and it points to CKModifyRecordsOperation, but being new to this I'm looking for more guidance. I'm setting my object like so:
[object setValue:number forKey:#"total"];
If I'm only modifying one record and not all do I still call CKModifyRecordsOperation?
Any clues to how this is done?
I've been using [self.cloudManager saveRecord:object]; but with modifying the record this isn't working.
You can fetch, modify, and save changes you make to individual records.
The code snippet below shows how to fetch an Artwork record, changes the date attribute value, and saves it to the database.
// Fetch the record from the database
CKDatabase *publicDatabase = [[CKContainer containerWithIdentifier:containerIdentifier] publicCloudDatabase];
CKRecordID *artworkRecordID = [[CKRecordID alloc] initWithRecordName:#"115"];
[publicDatabase fetchRecordWithID:artworkRecordID completionHandler:^(CKRecord *artworkRecord, NSError *error) {
if (error) {
// Error handling for failed fetch from public database
}
else {
// Modify the record and save it to the database
NSDate *date = artworkRecord[#"date"];
artworkRecord[#"date"]; = [date dateByAddingTimeInterval:30.0 * 60.0];
[publicDatabase saveRecord:artworkRecord completionHandler:^(CKRecord *savedRecord, NSError *saveError) {
// Error handling for failed save to public database
}];
}
}];
Consider to read this article for more detailed information.

Efficient way to perform bulk INSERT/UPDATE/DELETE in CoreData.

I've got a JSON object containing 200,000 items. I need to iterate through these objects, and determine if they exist or not and perform the relevant action (insert / update / delete). The shell for this is shown below. Granted, it's not actually saving anything yet. It was more to see how long this way would take. This action takes about 8 minutes to process on an iPhone 4, which seems insane, considering there isn't even any changes occurring yet.
Is there a more efficient way to be handling this?
Any advice or pointers would be greatly appreciated.
- (void) progressiveInsert
{
prodAdd = 0;
prodUpdate = 0;
prodDelete = 0;
dispatch_queue_t backgroundDispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
dispatch_async(backgroundDispatchQueue,
^{
_productDBCount = 0;
NSLog(#"Background Queue");
NSLog(#"Number of products in jsonArray: %lu", (unsigned long)[_products count]);
NSManagedObjectContext *backgroundThreadContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSConfinementConcurrencyType];
[backgroundThreadContext setPersistentStoreCoordinator:_persistentStoreCoordinator];
[backgroundThreadContext setUndoManager:nil];
[fetchRequest setPredicate:predicate];
[fetchRequest setEntity:[NSEntityDescription entityForName:#"Products" inManagedObjectContext:_managedObjectContext]];
[fetchRequest setIncludesSubentities:NO]; //Omit subentities. Default is YES (i.e. include subentities)
[fetchRequest setFetchLimit:1];
[_products enumerateObjectsUsingBlock:^(id product, NSUInteger idx, BOOL *stop) {
predicate = [NSPredicate predicateWithFormat:#"code == %#", [product valueForKey:#"product_code"]];
[fetchRequest setPredicate:predicate];
NSError *err;
NSArray *fetchedObjects = [_managedObjectContext executeFetchRequest:fetchRequest error:&err];
if (fetchedObjects == nil) {
if ([[product valueForKey:#"delete"] isEqualToNumber:[NSNumber numberWithBool:TRUE]]){
prodDelete += 1;
} else {
prodAdd += 1;
}
} else {
if ([[product valueForKey:#"delete"] isEqualToNumber:[NSNumber numberWithBool:TRUE]]){
prodDelete += 1;
} else {
prodUpdate += 1;
}
}
dispatch_sync(dispatch_get_main_queue(), ^
{
self.productDBCount += 1;
float progress = ((float)self.productDBCount / (float)self.totalCount);
_downloadProgress.progress = progress;
if (_productDBCount == _totalCount){
NSLog(#"Finished processing");
_endProcessing = [NSDate date];
[_btn.titleLabel setText:#"Finish"];
NSLog(#"Processing time: %f", [_endProcessing timeIntervalSinceDate:_startProcessing]);
NSLog(#"Update: %i // Add: %i // Delete: %i", prodUpdate, prodAdd, prodDelete);
[self completeUpdateProcess];
}
});
}];
});
}
Have a look at
Implementing Find-or-Create Efficiently in the "Core Data Programming Guide".
(Update: This chapter does not exist anymore in the current Core Data Programming Guide. An archived version can be found at
http://web.archive.org/web/20150908024050/https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/CoreData/Articles/cdImporting.html.)
One of the key ideas is not to execute one fetch request per product, but execute a
"bulk fetch" with a predicate like
[NSPredicate predicateWithFormat:#"code IN %#", productCodes]
where productCodes is an array of "many" product codes from your JSON data.
Of course you have to find the optimal "batch size".
With that many objects, I think you need to start being very clever about your data and system and to look for other ways to trim your items prior to fetching 200K JSON objects. You say your using Core Data and are on an iPhone, but you don't specify if this is a client/server application (hitting a web server from the phone). I will try to keep my suggestions general.
Really, you should think outside of your current JSON and more about other data/meta-data that can provides hints about what you really need to fetch prior to merge/update. It sounds like you're synchronizing two databases (phone & remote) and using JSON as your means of transfer.
Can you timestamp your data? If you know the last time you updated your phone DB, you need only pull the data changed after that time.
Can you send your data in sections/partitions? Groupings of 1000-10000 might be much more manageable.
Can you partition your data into sections more or less relevant to the user/app? In this way, items that the user touches first are updated first.
If your data is geographic, can you send data close to region of interest first?
If your data is products, can you send data that the user has looked at more recently first?
If your data is hierarchical, can you mark root nodes as changed (or again timestamp) and only update sub-trees that have changed?
I would be hesitant in any system, whether networked or even local DB, to attempt to merge updates from a 200K list of items unless it were a very simple list (like a numeric merge sort). It's a tremendous waste of time and network resources, and it won't make your customers very happy.
Don't work on individual items, batch them. Currently you make lots of fetch requests to the context and these take time (use the Core Data Instruments tool to take a look). If you set the batch size for your processing to 100 initially, then fetch that group of ids and then locally check for existence in the fetch results array.

Resources