Under the Sheets with iCloud and Core Data: Sentinels
In the previous posts I alluded to some exceptional circumstances that can arise and need to be handled in a production app. In particular,
- If an app is running, and the user disables iCloud, or deletes the iCloud container on any device, the app will crash.
- If an app has been syncing at some point in the past, and undergoes a stoppage — a period of time where it does not sync — it can’t pick up again from where it left off. When enabling syncing, there needs to be a way to determine if a device ever contributed to the transaction logs present in the iCloud container.
One solution to these problems is to add a file or files to the iCloud container to track which devices have contributed to the transaction logs. When a device starts syncing, an entry is made in the container. When the device stops syncing, the device entry is left in place as a record that it contributed to the iCloud logs.
If there is an attempt to enable syncing on a device which was syncing in the past, it will be clear from the device entries that a stoppage has arisen, and the existing transaction logs can be removed. And if the container is deleted altogether, the device records will also get deleted. The app can detect the missing files, tear down its Core Data stack, and, for example, setup again with a non-ubiquitous store, avoiding a crash and/or data corruption.
I will refer to the files used to track syncing devices as ‘sentinels’. This term was introduced by Daniel Pasco in a presentation at NSConference 2012. Daniel used a very similar technique to what I will describe here, and has posted his code on GitHub.
The Sentinel
Daniel’s solution involves adding a file for each device that contributes to the transaction logs. My solution, which was developed independently, is similar in nature, but uses just a single properly list file holding an array of device identifiers. Each time a device starts syncing, it is added to this plist file.
The plist can be used to check whether a device for which syncing is about to be enabled has contributed to the transaction logs in iCloud at some point in the past. And if the plist file is deleted, or a device with syncing enabled suddenly no longer appears in the list of identifiers, it can be concluded that a reset has occurred, and evasive action can be taken.
The Sentinel Class
I have developed a class that creates and tracks a sentinel file. To get the source code, visit the test project introduced last time on GitHub, or — if you cloned the source last time — pull down the latest changes using git.
The sentinel class is called MCCloudResetSentinel. To create an instance, you use the initWithCloudStorageURL:cloudSyncEnabled: initializer. The first argument should be a URL for a directory in the app’s iCloud container. The class will create the sentinel plist file in this directory.
The second argument indicates whether syncing is currently enabled. This relates to the in-app sync setting, and not to whether iCloud is enabled globally for the device. The MCCloudResetSentinel class always assumes iCloud is globally enabled; if that is not the case, the class serves no useful purpose, as it has no access to the iCloud container.
The sentinel class is immutable. Once you initialize it, either for the syncing or non-syncing state, you can’t change it. If the syncing state does need to change, simply release the existing sentinel instance, and make a new one.
The MCCloudResetSentinel has a delegate, and will invoke the delegate method cloudResetSentinelDidDetectReset: if the current device identifier disappears from the list of syncing devices while syncing is enabled. It will only invoke this method once; a new sentinel instance should be created after the reset has been addressed.
The class also posts a MCCloudResetSentinelDidDetectResetNotification notification when a reset occurs, so objects other than the delegate can also take appropriate action, such as release references to invalidated managed objects.
When a device starts syncing, the sentinel object can be requested to add it to the list of devices using the method updateDevicesList:. Because this method has to ensure it has the very latest version of the devices property list, which may involve downloading from iCloud servers, the method is asynchronous, and takes a completion handler block as sole argument.
The last piece of major functionality in the MCCloudResetSentinel class is the ability to check if the current device is in the property list, ie, has at some point contributed to the iCloud transaction logs. The asynchronous checkCurrentDeviceRegistration: method is used for this purpose. It invokes a completion handler, passing YES if the device is listed.
Using MCCloudResetSentinel
The AppDelegate class includes a number of changes to make use of the sentinel class, and the UI of the test app has also been improved somewhat. For example, the interface now shows icons to indicate whether iCloud syncing is active, and whether the Core Data stack is currently setup.
The sentinel is used in a few places. First, when the app launches, a check is done to see whether a cloud reset has occurred while the app was not running. This occurs before the Core Data stack is initialized.
[self checkIfCloudDataHasBeenReset:^(BOOL hasBeenReset) {
if ( hasBeenReset ) [self disableCloudAfterResetAndWarnUser];
[self setupCoreDataStack:self];
}];
The checkIfCloudDataHasBeenReset: method uses a sentinel to test for a reset.
-(void)checkIfCloudDataHasBeenReset:(void (^)(BOOL hasBeenReset))completionBlock
{
dispatch_queue_t completionQueue = dispatch_get_current_queue();
dispatch_retain(completionQueue);
BOOL usingCloudStorage =
[[NSUserDefaults standardUserDefaults] boolForKey:MCUsingCloudStorageDefault];
if ( usingCloudStorage && !self.cloudStoreURL ) {
dispatch_async(completionQueue, ^{
completionBlock(YES);
dispatch_release(completionQueue);
});
return;
}
// Use a temporary sentinel to determine if a reset of cloud data has occurred
MCCloudResetSentinel *tempSentinel =
[[MCCloudResetSentinel alloc] initWithCloudStorageURL:self.cloudStoreURL
cloudSyncEnabled:usingCloudStorage];
if ( usingCloudStorage ) {
[tempSentinel checkCurrentDeviceRegistration:^(BOOL deviceIsPresent) {
dispatch_async(completionQueue, ^{
completionBlock(!deviceIsPresent);
dispatch_release(completionQueue);
});
}];
}
else {
dispatch_async(completionQueue, ^{
completionBlock(NO);
dispatch_release(completionQueue);
});
}
}
This code is asynchronous and makes heavy use of Grand Central Dispatch (GCD), but the idea is fairly straightforward: if cloud syncing is enabled, which is determined by a user default, a temporary sentinel is created to check if the current device is in the device list. The result is passed back to the completion block.
Whenever the Core Data stack is setup, a sentinel is created to update the devices list, and then monitor it continuously for iCloud container resets.
__weak AppDelegate *weakSelf = self;
[self addStoreToPersistentStoreCoordinator:^(BOOL success, NSError *error) {
if ( !success ) {
[weakSelf tearDownCoreDataStack:weakSelf];
[[NSApplication sharedApplication] presentError:error];
}
else {
[weakSelf makeManagedObjectContext];
// Setup a sentinel
BOOL usingCloudStorage =
[[NSUserDefaults standardUserDefaults] boolForKey:MCUsingCloudStorageDefault];
if ( usingCloudStorage ) {
weakSelf->sentinel =
[[MCCloudResetSentinel alloc] initWithCloudStorageURL:weakSelf.cloudStoreURL
cloudSyncEnabled:usingCloudStorage];
weakSelf->sentinel.delegate = self;
[weakSelf->sentinel updateDevicesList:NULL];
}
The other place a sentinel is used is when the user enables syncing. At this point, the sentinel is used to check if the device ever contributed transaction logs, and act accordingly.
// Use sentinel to determine if device was previously syncing.
// In that case, the only option is to replace the whole cloud container.
// If the device never synced before, the user can choose to keep the
// local or the cloud data.
MCCloudResetSentinel *tempSentinel =
[[MCCloudResetSentinel alloc] initWithCloudStorageURL:self.cloudStoreURL
cloudSyncEnabled:NO];
[tempSentinel stopMonitoringDevicesList];
[tempSentinel checkCurrentDeviceRegistration:^(BOOL deviceIsPresent) {
if ( deviceIsPresent ) {
// Only choice is to move data to the cloud, replacing the existing cloud data.
// In a production app, you should warn the user, and give them a chance
// to back out.
[self migrateStoreToCloud];
}
else {
// Can keep either the cloud data, or the local data at this point
// In a production app, you could ask the user what they want to keep.
// Here we will just see if there is cloud data present, and if there is,
// use that. If there is no cloud data, we'll keep the local data.
BOOL migrateDataFromCloud =
[[NSFileManager defaultManager] fileExistsAtPath:self.cloudStoreURL.path];
if ( migrateDataFromCloud ) {
// Already cloud data present, so replace local data with it
[self removeLocalFiles:self];
}
else {
// No cloud data, so migrate local data to the cloud
[self migrateStoreToCloud];
}
}
[[NSUserDefaults standardUserDefaults] setBool:YES
forKey:MCUsingCloudStorageDefault];
[self setupCoreDataStack:self];
}];
The comments in the code describe the various branches that can be taken. In short, if the device is in the list already, the only way to get a consistent set of data is to replace the iCloud container with data from the local store. If the device is not in the list, the option arises to either replace the iCloud data, or replace the local store.
Metadata — Faster than Some Speeding Data
I will use the rest of this post to go through the code of the sentinel class itself, because it is useful to understanding iCloud and how you interact with it.
One aspect of iCloud’s implementation that is useful to grasp is that it pushes metadata between devices and the cloud much faster than it moves file data itself. An app on one device can learn of a change on another device long before the file change itself has propagated over.
You can use the NSMetadataQuery class to monitor changes in file metadata, and Apple have extended the API to support ubiquitous data in the iCloud container. This is the same class you use to do Spotlight searches, and also underpins Time Machine backups.
You can do asynchronous searches with NSMetadataQuery, but also be notified of file metadata changes as they occur. The latter is where our interest lies here. The MCCloudResetSentinel class sets up a query in its initializer that matches the filename of the devices property list, and then listens for NSMetadataQueryDidUpdateNotification notifications from the query.
// Listen for changes in the metadata of the devices list
devicesListMetadataQuery = [[NSMetadataQuery alloc] init];
devicesListMetadataQuery.searchScopes =
[NSArray arrayWithObject:NSMetadataQueryUbiquitousDataScope];
devicesListMetadataQuery.predicate =
[NSPredicate predicateWithFormat:@"%K like %@", NSMetadataItemFSNameKey, MCSyncingDevicesListFilename];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(devicesListDidUpdate:)
name:NSMetadataQueryDidUpdateNotification object:devicesListMetadataQuery];
if ( ![devicesListMetadataQuery startQuery] ) NSLog(@"Failed to start devices list NSMetadataQuery");
The predicate of the query is used to restrict notifications to files named the same as the devices property list file, and the scope of the query — NSMetadataQueryUbiquitousDataScope — means only files in the iCloud outside of the Documents folder will be monitored. (If you want to monitor files inside the iCloud Documents folder, use NSMetadataQueryUbiquitousDocumentsScope.)
Downloading Files
Because metadata moves faster than file data, there may be times where your app knows about a change before it has happened, and needs to get hold of the new data as quickly as possible, rather than just waiting for iCloud to do its thing. This is the case, for example, with the devices list: if a reset has occurred, we want to know about it as soon as possible, so that we can take evasive action.
Luckily, Apple have built a few features into NSURL and NSFileManager to help with this. First, you can query the download state of a particular file using NSURL’s getResourceValue:forKey:error:, passing in NSURLUbiquitousItemIsDownloadedKey as the key. The value returned by reference contains an NSNumber holding a BOOL to indicate if the file is downloaded, or still has changes in the cloud.
If a file is not downloaded, you can force it to download using the startDownloadingUbiquitousItemAtURL:error: method. While it is downloading, the resource key NSURLUbiquitousItemIsDownloadingKey can be used with getResourceValue:forKey:error: to check if it is still downloading.
A lot of the time, you only care to ensure that you have the latest version of a file, and don’t need to know the details of how it is retrieved. For that reason, I added a category method to NSURL that makes sure the latest version of an iCloud file is present on the local disk.
-(void)syncWithCloud:(void (^)(BOOL success, NSError *error))completionBlock
{
NSError *error;
NSNumber *downloaded;
BOOL success = [self getResourceValue:&downloaded forKey:NSURLUbiquitousItemIsDownloadedKey error:&error];
if ( !success ) {
// Resource doesn't exist
completionBlock(YES, nil);
return;
}
if ( !downloaded.boolValue ) {
NSNumber *downloading;
BOOL success = [self getResourceValue:&downloading forKey:NSURLUbiquitousItemIsDownloadingKey error:&error];
if ( !success ) {
completionBlock(NO, error);
return;
}
if ( !downloading.boolValue ) {
BOOL success = [[NSFileManager defaultManager] startDownloadingUbiquitousItemAtURL:self error:&error];
if ( !success ) {
completionBlock(NO, error);
return;
}
}
// Download not complete. Schedule another check.
double delayInSeconds = 0.1;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
dispatch_queue_t queue = dispatch_get_current_queue();
dispatch_retain(queue);
dispatch_after(popTime, queue, ^{
[self syncWithCloud:[completionBlock copy]];
dispatch_release(queue);
});
}
else {
completionBlock(YES, nil);
}
}
This uses the methods discussed above to download the latest version, and waits until it is finished, before calling back to a completion block.
File Presenters
Unfortunately, I have found that sometimes the metadata query notification does not fire when the device list file is deleted. I suspect this may have something to do with the file deletion taking place at the same time as the metadata update arriving, but I’m not certain.
To make doubly sure that the sentinel class observes all changes to the device list file, I have made it conform to the NSFilePresenter protocol.
File presenters are the flip side of file coordinators, which we met last time. File coordinators provide a locking mechanism for safely manipulating files, and notify any file presenters when things change.
So in order to monitor changes to a file, you can register a file presenter, which is an object conforming to the NSFilePresenter protocol. The protocol requires the following methods.
-(NSURL *)presentedItemURL
{
return self.syncedDevicesListURL;
}
-(NSOperationQueue *)presentedItemOperationQueue
{
return filePresenterQueue;
}
The presentedItemURL returns the URL of the presented file, and presentedItemOperationQueue should provide the queue that is used for callbacks when the file changes.
The methods for observing changes are as follows:
-(void)presentedItemDidChange
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[self devicesListDidUpdate:nil];
}];
}
-(void)accommodatePresentedItemDeletionWithCompletionHandler:(void (^)(NSError *errorOrNil))completionHandler
{
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
[self devicesListDidUpdate:nil];
}];
completionHandler(nil);
}
The first is for a change to the file, and the second is for a deletion. In both cases, we queue up a device list check on the main queue.
The only other aspect of NSFilePresenter that should be considered is how it starts and stops monitoring. To begin monitoring the file, you actually need to use the NSFileCoordinator class.
// Register as file presenter
[NSFileCoordinator addFilePresenter:self];
And to stop monitoring, you remove the presenter.
-(void)stopMonitoringDevicesList
{
[NSFileCoordinator removeFilePresenter:self];
Last Throw…
That’s it for sentinels. Next time I will finish off the series by discussing troubleshooting and debugging.