How to implement a CoreData Custom Merge Policy?

919 views Asked by At

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.

1

There are 1 answers

0
Reinhard Männer On BEST ANSWER

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:

  • to find first the properties of the latest updatedAt (it can be kept in the source object, the object snapshot, the cached snapshot or in the persistent snapshot),
  • to store these properties,
  • to apply the default merge policy on that the custom merge policy is based,
  • to set if required the objects properties to the stored newest values.

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")
    }
}