ioscore-datansmergepolicymerge-conflict

How to implement a CoreData Custom Merge Policy?


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.


Solution

  • 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:

    Only then is the conflict resolved.

    The correct implementation that I am using now is:

    override func resolve(optimisticLockingConflicts list: [NSMergeConflict]) throws {
        for conflict in list {
            let sourceObject = conflict.sourceObject
            // Only UpdateTimestampable objects can use the custom merge policy. Other use the default merge policy.
            guard sourceObject is UpdateTimestampable else {
                try super.resolve(optimisticLockingConflicts: [conflict])
                continue
            }
            let newestSnapshot = conflict.newestSnapShot
            
            if let sourceObject = sourceObject as? Item {
                let fixedAtTopAt: Date?
                let howOftenBought: Int32
                let lastBoughtDate: Date?
                let name: String
                let namesOfBuyPlaces: Set<String>?
                let status: Int16
                let updatedAt: Date?
                
                let sourceObjectUpdatedAt = sourceObject.updatedAt ?? .distantPast
                if sourceObjectUpdatedAt >= newestSnapshot?["updatedAt"] as? Date ?? .distantPast {
                    fixedAtTopAt = sourceObject.fixedAtTopAt
                    howOftenBought = sourceObject.howOftenBought
                    lastBoughtDate = sourceObject.lastBoughtDate
                    name = sourceObject.name
                    namesOfBuyPlaces = sourceObject.namesOfBuyPlaces
                    status = sourceObject.status
                    updatedAt = sourceObject.updatedAt
                } else {
                    fixedAtTopAt = newestSnapshot?["fixedAtTopAt"] as? Date
                    howOftenBought = newestSnapshot?["howOftenBought"] as! Int32
                    lastBoughtDate = newestSnapshot?["lastBoughtDate"] as? Date
                    name = newestSnapshot?["name"] as! String
                    namesOfBuyPlaces = newestSnapshot?["namesOfBuyPlaces"] as? Set<String>
                    status = newestSnapshot?["status"] as! Int16
                    updatedAt = newestSnapshot?["updatedAt"] as? Date
                }
                // Here, all properties of the newest Item or Item snapshot have been stored.
                // Apply now the default merging policy to this conflict.
                try super.resolve(optimisticLockingConflicts: [conflict])
                // Overwrite now the source object's properties where necessary
                if sourceObject.fixedAtTopAt != fixedAtTopAt { sourceObject.fixedAtTopAt = fixedAtTopAt }
                if sourceObject.howOftenBought != howOftenBought { sourceObject.howOftenBought = howOftenBought }
                if sourceObject.lastBoughtDate != lastBoughtDate { sourceObject.lastBoughtDate = lastBoughtDate }
                if sourceObject.name != name { sourceObject.name = name }
                if sourceObject.namesOfBuyPlaces != namesOfBuyPlaces { sourceObject.namesOfBuyPlaces = namesOfBuyPlaces }
                if sourceObject.status != status { sourceObject.status = status }
                if sourceObject.updatedAt != updatedAt { sourceObject.updatedAt = updatedAt }
                continue
            } // source object is an Item
            
            if let sourceObject = conflict.sourceObject as? Place {
                // code for Place object …
            }
        }
    }  
    

    Here, newestSnapShot is an NSMergeConflict extension:

    extension NSMergeConflict {
        var newestSnapShot: [String: Any?]? {
            guard sourceObject is UpdateTimestampable else { fatalError("must be UpdateTimestampable") }
            let key = Schema.UpdateTimestampable.updatedAt.rawValue
            /* Find the newest snapshot.
             Florian Kugler: Core Data:
             Note that some of the snapshots can be nil, depending on the kind of conflict you’re dealing with. 
             For example, if the conflict occurs between the context and the row cache, the persisted snapshot will be nil. 
             If the conflict happens between the row cache and the persistent store, the object snapshot will be nil. 
             */
            let objectSnapshotUpdatedAt = objectSnapshot?[key] as? Date ?? .distantPast
            let cachedSnapshotUpdatedAt = cachedSnapshot?[key] as? Date ?? .distantPast
            let persistedSnapshotUpdatedAt = persistedSnapshot?[key] as? Date ?? .distantPast
            if persistedSnapshotUpdatedAt >= objectSnapshotUpdatedAt && persistedSnapshotUpdatedAt >= cachedSnapshotUpdatedAt {
                return persistedSnapshot
            }
            if cachedSnapshotUpdatedAt >= persistedSnapshotUpdatedAt && cachedSnapshotUpdatedAt >= objectSnapshotUpdatedAt {
                return cachedSnapshot
            }
            if objectSnapshotUpdatedAt >= persistedSnapshotUpdatedAt && objectSnapshotUpdatedAt >= cachedSnapshotUpdatedAt {
                return objectSnapshot
            }
            fatalError("No newest snapshot found")
        }
    }