My app uses CoreData + CloudKit synchronization. Some CoreData entities like Item
can be shared via iCloud's shared database. The app uses only 1 NSPersistentContainer
, but it has 2 NSManagedContexts
, the visualContext
and a backgroundContext
.
Thus during saving of a context, 2 types of merging conflicts can arise: 1) If both contexts try to save the same Item
in different states, and 2) If my persistent container and iCloud sync try to save the same Item
in different states.
Item
has an attribute updatedAt
, and the app requires that always the Item
version updated last should be saved.
For consistency reasons, I cannot merge by property. Only complete Item
objects can be stored, either one of both stored in a managed context, or either the one stored in a managed context or the one persistently stored.
But the standard merge policies cannot be used: NSRollbackMergePolicy
ignores changes in a managed context, and takes the persistent copy, while NSOverwriteMergePolicy
overwrites the persistent store with the object in the managed context. But I have to use the Item
with the newest updatedAt
. Thus I have to use a custom merge policy.
It was not easy to find any hint how to do this. I found two tutorials with demo code. The best one is the book Core Data by Florian Kugler and Daniel Eggert that has a section about Custom Merge Policies, and related code here. The other is a post by Deepika Ramesh with code. However I have to admit, I did not understand both fully. But based on their code, I tried to setup my own custom merge policy, that will be assigned to the mergePolicy
property of both managed contexts. Here it is:
import CoreData
protocol UpdateTimestampable {
var updatedAt: Date? { get set }
}
class NewestItemMergePolicy: NSMergePolicy {
init() {
super.init(merge: .overwriteMergePolicyType)
}
override open func resolve(optimisticLockingConflicts list: [NSMergeConflict]) throws {
let nonItemConflicts = list.filter({ $0.sourceObject.entity.name != Item.entityName })
try super.resolve(optimisticLockingConflicts: nonItemConflicts)
let itemConflicts = list.filter({ $0.sourceObject.entity.name == Item.entityName })
itemConflicts.forEach { conflict in
guard let sourceObject = conflict.sourceObject as? UpdateTimestampable else { fatalError("must be UpdateTimestampable") }
let key = "updatedAt"
let sourceObjectDate = sourceObject.updatedAt ?? .distantPast
let objectDate = conflict.objectSnapshot?[key] as? Date ?? .distantPast
let cachedDate = conflict.cachedSnapshot?[key] as? Date ?? .distantPast
let persistedDate = conflict.persistedSnapshot?[key] as? Date ?? .distantPast
let latestUpdateAt = [sourceObjectDate, objectDate, cachedDate, persistedDate].max()
let persistedDateIsLatest = persistedDate == latestUpdateAt
let sourceObj = conflict.sourceObject
if let context = sourceObj.managedObjectContext {
context.performAndWait {
context.refresh(sourceObj, mergeChanges: !persistedDateIsLatest)
}
}
}
try super.resolve(optimisticLockingConflicts: itemConflicts)
}
}
My first question is if this code makes sense at all. I am asking this because merging conflicts are hard to test.
Specifically, I have apparently to use any of the standard merging properties in super.init(merge: .overwriteMergePolicyType)
, although is is apparently not important which one, since I am using custom merge conflict resolution.
The code in the question is wrong:
It filters out first conflicts for non-
Item
objects, and calls super for them. This is correct.Then it loops over conflicts for
Item
objects to resolve them. There, it first applies the default merge policy (super
) and then refreshes the object in the context where merging is done if the persistent snapshot is newest. One reason why this is wrong is that the persistent snapshot can be nil.A correct resolution requires:
updatedAt
(it can be kept in the source object, the object snapshot, the cached snapshot or in the persistent snapshot),Only then is the conflict resolved.
The correct implementation that I am using now is:
Here,
newestSnapShot
is anNSMergeConflict
extension: