There seems to be a bug with NSFetchedResultsController whenever a prepareForDelete updates the model when the cause of the deletion is a cascade delete rule.
It seems to imply that an implicit delete (via cascade delete) behaves very differently than an explicit delete.
Is this really a bug, or can you explain why I am seeing these strange results?
Setting up the Project
You can skip this entire section and download the xcodeproj instead.
Create a new project with the Master-Detail Application template.
Add a new attribute to the Event entity. (This is important since we want to be able to update an attribute without it causing the NSFetchedResultsController to reorder any of its items. Otherwise it will send the
NSFetchedResultsChangeMove
event rather than theNSFetchedResultsChangeUpdate
event).Call the attribute
hasMovedUp
, and make it aBoolean
. (Note: it may seem silly to create such an attribute, but this is only an example, and I tried to reduce it to the minimum number of steps needed in order to reproduce this bug.)Add a new entity, call it
EventParent
.Create a relationship to Event, call it
child
. Make the inverse relationship as well, call itparent
. (Note: this is a 1:1 relationship.)Click on EventParent. Click on its child relationship. Set its Delete Rule to Cascade. The idea is that we will only be deleting parent objects. When the parent is deleted, it will automatically delete its child.
Leave the Event's parent relationship Delete Rule as Nullify.
Create NSManagedObject Subclasses via Xcode for both entities.
In the
insertNewObject:
method, where the new Event is created, make sure to create a corresponding parent.In the
Event.m
file, automatically assign the last event'shasMovedUp
to beYES
by declaring aprepareForDeletion
event:NSLog(@"Prepare for deletion"); NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Event"]; NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"timeStamp" ascending:NO]; [fetchRequest setSortDescriptors:@[sortDescriptor]]; NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:nil]; NSAssert(results, nil); Event *lastEvent = results.lastObject; NSLog(@"Updating event: %@", lastEvent.timeStamp); lastEvent.hasMovedUp = @YES; [super prepareForDeletion];
In the Storyboard, delete the segue to the DetailViewController. We won't be needing it.
Add some log statements in the
didChangeObject
event in the case of aNSFetchedResultsChangeDelete
andNSFetchedResultsChangeUpdate
. Have it outputindexPath.row
.Finally, make it so that when a cell is tapped, its corresponding parent is deleted. Do this by creating the
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
in theMasterViewController.m
file:NSManagedObjectContext *context = [self.fetchedResultsController managedObjectContext]; Event *event = [self.fetchedResultsController objectAtIndexPath:indexPath]; EventParent *parent = event.parent; NSLog(@"Deleting event: %@", event.timeStamp); [context deleteObject:parent]; //[context deleteObject:event]; // comment and uncomment this line to reproduce or fix the error, respectively.
Summary of the setup so far:
- We are not going to touch the NSFetchedResultsController much. We will allow it to observe and show Events.
- Whenever we delete an EventParent, we want its corresponding Event to be deleted.
- To add another twist, we want the
hasMovedUp
property to be updated whenever an Event is deleted.
Reproducing the bug
Run the App
Create 2 records by tapping the plus button twice.
Tap the top record and watch the app crash (Note: 95% of the time it will crash. If it doesn't crash for you, restart the app until it does). Here are some useful NSLogs:
2013-07-09 13:38:26.984 ReproNFC_PFD_bug[9518:11603] Deleting event: 2013-07-09 20:28:30 +0000 2013-07-09 13:38:26.986 ReproNFC_PFD_bug[9518:11603] Prepare for deletion 2013-07-09 13:38:26.987 ReproNFC_PFD_bug[9518:11603] Updating event: 2013-07-09 02:48:49 +0000 2013-07-09 13:38:26.989 ReproNFC_PFD_bug[9518:11603] Delete detected on row: 0 2013-07-09 13:38:26.990 ReproNFC_PFD_bug[9518:11603] Update detected on row: 1
Now uncomment the
[context deleteObject:event]
line above.Run the app and notice that it no longer crashes. The logs:
2013-07-09 13:20:19.917 ReproNFC_PFD_bug[8997:11603] Deleting event: 2013-07-09 20:20:03 +0000 2013-07-09 13:20:19.919 ReproNFC_PFD_bug[8997:11603] Prepare for deletion 2013-07-09 13:20:19.921 ReproNFC_PFD_bug[8997:11603] Delete detected on row: 0 2013-07-09 13:20:19.924 ReproNFC_PFD_bug[8997:11603] Updating event: 2013-07-09 02:48:49 +0000 2013-07-09 13:20:19.925 ReproNFC_PFD_bug[8997:11603] Update detected on row: 0
Two things are different in the logs:
The deletion is detected before we update the next Event.
The update takes place on row 0 (the correct row) rather than row 1 (the incorrect row). Read on for an explanation of why 0 is the correct number.
(Note: even during that 5% of the time when we expect the error to occur but it doesn't, the log events are outputed in the same exact order.)
The Exception
The exception is raised on the following line in configureCell:atIndexPath:
:
NSManagedObject *object = [self.fetchedResultsController objectAtIndexPath:indexPath];
The reason it causes an exception is because the update is detected on a row that no longer exists (1). Notice that when the exception does not occur, the update is detected on the correct row (0), since the top row would have been deleted, and the bottom row is now at index 0.
The exception raised is:
CoreData: error: Serious application error. Exception was caught during Core Data change processing. This is usually a bug within an observer of NSManagedObjectContextObjectsDidChangeNotification. *** -[_PFBatchFaultingArray objectAtIndex:]: index (19789522) beyond bounds (2) with userInfo (null)
.
* Terminating app due to uncaught exception 'NSRangeException', reason: '* -[_PFBatchFaultingArray objectAtIndex:]: index (19789522) beyond bounds (2)'
Implications
This seems to suggest that relying on the cascade delete rule is not the same as explicitly deleting the object yourself.
In other words...
This:
[context deleteObject:parent];
// parent will auto-delete the corresponding Event via a cascade rule
… is not the same as this:
[context deleteObject:parent];
[context deleteObject:event];
Workarounds
Update 6/9/13:
The Xcodeproj was updated to include several #define
statements for the different workarounds available (in the Event.h file). Leave all 3 undefined to reproduce the bug. Define any 1 of these to see a particular workaround implemented. So far there are three workarounds: A, B, and C.
A: Explicitly calling delete
This solution is a duplicate of what has already been mentioned above, but it is included for completeness sake.
By not relying on the Cascade Delete, and instead calling the delete yourself, everything will work fine:
// (CUSTOMIZATION_POINT A)
[context deleteObject:parent]; // A1: this line should always run
#ifdef Workaround_A
[context deleteObject:event]; // A2: this line will fix the bug
#endif
Logs:
2013-07-09 13:20:19.917 ReproNFC_PFD_bug[8997:11603] Deleting event: 2013-07-09 20:20:03 +0000
2013-07-09 13:20:19.919 ReproNFC_PFD_bug[8997:11603] Prepare for deletion
2013-07-09 13:20:19.921 ReproNFC_PFD_bug[8997:11603] Delete detected on row: 0
2013-07-09 13:20:19.924 ReproNFC_PFD_bug[8997:11603] Updating event: 2013-07-09 02:48:49 +0000
2013-07-09 13:20:19.925 ReproNFC_PFD_bug[8997:11603] Update detected on row: 0
B: Using @MartinR's recommendation:
By ignoring the indexPath
parameter, and only using the anObject
parameter in the didChangeObject:
method, you can circumvent the problem:
case NSFetchedResultsChangeUpdate:
NSLog(@"Update detected on row: %d", indexPath.row);
// (CUSTOMIZATION_POINT B)
#ifndef Workaround_B
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] atIndexPath:indexPath]; // B1: causes bug
#else
[self configureCell:[tableView cellForRowAtIndexPath:indexPath] withObject:anObject]; // B2: doesn't cause bug
#endif
break;
However, the logs still display things out of order:
2013-07-09 13:24:43.662 ReproNFC_PFD_bug[9101:11603] Deleting event: 2013-07-09 20:24:42 +0000
2013-07-09 13:24:43.663 ReproNFC_PFD_bug[9101:11603] Prepare for deletion
2013-07-09 13:24:43.666 ReproNFC_PFD_bug[9101:11603] Updating event: 2013-07-09 02:48:49 +0000
2013-07-09 13:24:43.667 ReproNFC_PFD_bug[9101:11603] Delete detected on row: 0
2013-07-09 13:24:43.667 ReproNFC_PFD_bug[9101:11603] Update detected on row: 1
Which leads me to believe that this solution could cause related problems in other parts of my code.
C: Using a 0-second delay in prepareForDelete:
If you update the object after a zero-second delay in the prepare for delete, this will circumvent the bug:
- (void)updateLastEventInContext:(NSManagedObjectContext *)context {
// warning: do not call self.<anything> in this method when it is called with a delay, since the object would have already been deleted
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Event"];
NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"timeStamp" ascending:NO];
[fetchRequest setSortDescriptors:@[sortDescriptor]];
NSArray *results = [context executeFetchRequest:fetchRequest error:nil];
NSAssert(results, nil);
Event *lastEvent = results.lastObject;
NSLog(@"Updating event: %@", lastEvent.timeStamp);
lastEvent.hasMovedUp = @YES;
}
- (void)prepareForDeletion {
NSLog(@"Prepare for deletion");
// (CUSTOMIZATION_POINT C)
#ifndef Workaround_C
[self updateLastEventInContext:self.managedObjectContext]; // C1: causes the bug
#else
[self performSelector:@selector(updateLastEventInContext:) withObject:self.managedObjectContext afterDelay:0]; // C2: doesn't cause the bug
#endif
[super prepareForDeletion];
}
Additionally, the log order seems to be correct, so you can resume calling indexPath on the NSFetchedResultsController (I.e. you don't need to use workaround B):
2013-07-09 13:27:38.308 ReproNFC_PFD_bug[9196:11603] Deleting event: 2013-07-09 20:27:37 +0000
2013-07-09 13:27:38.309 ReproNFC_PFD_bug[9196:11603] Prepare for deletion
2013-07-09 13:27:38.310 ReproNFC_PFD_bug[9196:11603] Delete detected on row: 0
2013-07-09 13:27:38.319 ReproNFC_PFD_bug[9196:11603] Updating event: 2013-07-09 02:48:49 +0000
2013-07-09 13:27:38.320 ReproNFC_PFD_bug[9196:11603] Update detected on row: 0
However, this means you cannot access self.timeStamp
, for example, in the updateLastEventInContext:
method, since the object will already have been deleted at that point (this is assuming you save the context immediately after the call to delete the parent object).