Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to properly use query generation tokens?

I'm trying to get an example project using CoreData and QueryGenerationTokens working. The essence of the project is to be committing changes to a background context on a timer (emulating changes coming down from a server) that shouldn't be displayed until an action is taken on the UI (say, a button press).

Currently, I have changes being saved on the background context (an entity is being added every 5s and saved) and they are automatically coming into the view context (as expected, .automaticallyMergesChangesFromParent is set to true). Where things go wrong, I am pinning the view context before any of these changes happen to the current query generation token. I would expect the view to not update with the background items being added, but it is updating with them. So it seems the query generation tokens are having no effect?

Some of the possible issues I've thought of:

  • the only example I've found from Apple doesn't show them using it with a fetched results controller (I'm using @FetchRequest in SwiftUI, which I'm almost entirely certain is essentially the same), so that may have an effect?
  • .automaticallyMergeChangesFromParent shouldn't be used and I should try a merge policy, but that doesn't seem to work either and conceptually, it seems the query generation tokens should work with this and pin to the generation no matter the merging.

Code for view - handles loading data from view context

// Environment object before fetch request necessary
// Passed in wherever main view is instantiated through .environment()
@Environment(\.managedObjectContext) var managedObjectContext

// Acts as fetched results controller, loading data automatically into items upon the managedObjectContext updating
// ExampleCoreDataEntity.retrieveItemsFetchRequest() is an extension method on the entity to easily get a fetch request for the type with sorting
@FetchRequest(fetchRequest: ExampleCoreDataEntity.retrieveItemsFetchRequest()) var items: FetchedResults<ExampleCoreDataEntity>

var body: some View {
    NavigationView {
        // Button to refresh and bring in changes
        Button(
            action: {
                do {
                    try self.managedObjectContext.setQueryGenerationFrom(.current)
                    self.managedObjectContext.refreshAllObjects()
                } catch {
                    print(error.localizedDescription)
                }
            },
            label: { Image(systemName: "arrow.clockwise") }
        )

        // Creates a table of items sorted by the entity itself (entities conform to Hashable)
        List(self.items, id: \.self) { item in
            Text(item.name ?? "")
        }
    }
}

Code in SceneDelegate (where a SwiftUI application starts up) where I also initialize what is needed for CoreData:

// Setup and pass in environment of managed object context to main view 
// via extension on persistent container that sets up CoreData stack
let managedObjectContext = NSPersistentContainer.shared.viewContext
do {
    try managedObjectContext.setQueryGenerationFrom(.current)
} catch {
    print(error.localizedDescription)
}
let view = MainView().environment(\.managedObjectContext, managedObjectContext)

// Setup background adding
timer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(backgroundCode), userInfo: nil, repeats: true)

// Setup window and pass in main view
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: view)

Function adding data in the background:

@objc func backgroundCode() {
    ExampleCoreDataEntity.create(names: ["background object"], in: backgroundContext, shouldSave: true)
}   

Setup of NSPersistentContainer:

extension NSPersistentContainer {

    private struct SharedContainerStorage {
        static let container: NSPersistentContainer = {
            let container = NSPersistentContainer(name: "Core_Data_Exploration")
            container.loadPersistentStores { (description, error) in
                guard error == nil else {
                    assertionFailure("CoreData: Unresolved error \(error!.localizedDescription)")
                    return
                }
                container.viewContext.automaticallyMergesChangesFromParent = true
            }
            return container
        }()
    }

    static var shared: NSPersistentContainer {
        return SharedContainerStorage.container
    }
}

Create/Read/Update/Delete functions on the entity:

extension ExampleCoreDataEntity {
    static func retrieveItemsFetchRequest() -> NSFetchRequest<ExampleCoreDataEntity> {
        let request: NSFetchRequest<ExampleCoreDataEntity> = ExampleCoreDataEntity.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \ExampleCoreDataEntity.creationDate, ascending: false)]
        return request
    }

    static func create(names: [String], in context: NSManagedObjectContext, shouldSave save: Bool = false) {
        context.perform {
            names.forEach { name in
                let item = ExampleCoreDataEntity(context: context)
                item.name = name
                item.creationDate = Date()
                item.identifier = UUID()
            }

            do {
                if save {
                    try context.save()
                }
            } catch {
                // print error
            }
        }
    }

    func delete(in context: NSManagedObjectContext, shouldSave save: Bool = false) {
        context.perform {
            let name = self.name ?? "an item"

            context.delete(context.object(with: self.objectID))
            do {
                if save {
                    try context.save()
                }
            } catch {
                // print error
            }
        }
    }
}
like image 603
Matthew Gray Avatar asked Aug 13 '19 18:08

Matthew Gray


2 Answers

The issue was container.viewContext.automaticallyMergesChangesFromParent = true

That property cannot be set to true while working with query generation tokens. I came back to this issue and found this in the header of NSManagedObjectContext documented above automaticallyMergesChangesFromParent:

Setting this property to YES when the context is pinned to a non-current query generation is not supported.

The general flow of getting it to work is the following:

  • setting the query generation token to .current
  • calling .refreshAllObjects() on the view context
  • calling .performFetch() on the fetched results controller

This last part goes against the code I put in the original question which used @FetchRequest - currently, I can't figure out a way that doesn't seem extremely hacky to make it manually refetch. To get around this, I made an intermediate store class containing a FetchedResultsController that adopts its delegate protocol. That store also adopts ObservableObject which allows a SwiftUI view to listen to its changes when calling objectWillChange.send() within the ObservableObject adopting store.

like image 79
Matthew Gray Avatar answered Sep 20 '22 13:09

Matthew Gray


In the documentation you linked to in the question you will see it says:

"Calling save(), reset(), mergeChangesFromContextDidSaveNotification:, or mergeChangesFromRemoteContextSave(:intoContexts:) on any pinned context will automatically advance it to the most recent version for the operation and then reset its query generation to currentQueryGenerationToken."

The reason you are seeing the changes from the background save is automaticallyMergesChangesFromParent is just convenience for mergeChangesFromContextDidSaveNotification so your generation is advancing.

FYI here is another sample project uses query generations - Synchronizing a Local Store to the Cloud

And here is the relevant code:

/*
See LICENSE folder for this sample’s licensing information.

Abstract:
A class to set up the Core Data stack, observe Core Data notifications, process persistent history, and deduplicate tags.
*/

import Foundation
import CoreData

// MARK: - Core Data Stack

/**
 Core Data stack setup including history processing.
 */
class CoreDataStack {
    
    /**
     A persistent container that can load cloud-backed and non-cloud stores.
     */
    lazy var persistentContainer: NSPersistentContainer = {
        
        // Create a container that can load CloudKit-backed stores
        let container = NSPersistentCloudKitContainer(name: "CoreDataCloudKitDemo")
        
        // Enable history tracking and remote notifications
        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("###\(#function): Failed to retrieve a persistent store description.")
        }
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        container.loadPersistentStores(completionHandler: { (_, error) in
            guard let error = error as NSError? else { return }
            fatalError("###\(#function): Failed to load persistent stores:\(error)")
        })
        
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.transactionAuthor = appTransactionAuthorName
        
        // Pin the viewContext to the current generation token and set it to keep itself up to date with local changes.
        container.viewContext.automaticallyMergesChangesFromParent = true
        do {
            try container.viewContext.setQueryGenerationFrom(.current)
        } catch {
            fatalError("###\(#function): Failed to pin viewContext to the current generation:\(error)")
        }
        
        // Observe Core Data remote change notifications.
        NotificationCenter.default.addObserver(
            self, selector: #selector(type(of: self).storeRemoteChange(_:)),
            name: .NSPersistentStoreRemoteChange, object: container)
        
        return container
    }()

    /**
     Track the last history token processed for a store, and write its value to file.
     
     The historyQueue reads the token when executing operations, and updates it after processing is complete.
     */
    private var lastHistoryToken: NSPersistentHistoryToken? = nil {
        didSet {
            guard let token = lastHistoryToken,
                let data = try? NSKeyedArchiver.archivedData( withRootObject: token, requiringSecureCoding: true) else { return }
            
            do {
                try data.write(to: tokenFile)
            } catch {
                print("###\(#function): Failed to write token data. Error = \(error)")
            }
        }
    }
    
    /**
     The file URL for persisting the persistent history token.
    */
    private lazy var tokenFile: URL = {
        let url = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("CoreDataCloudKitDemo", isDirectory: true)
        if !FileManager.default.fileExists(atPath: url.path) {
            do {
                try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
            } catch {
                print("###\(#function): Failed to create persistent container URL. Error = \(error)")
            }
        }
        return url.appendingPathComponent("token.data", isDirectory: false)
    }()
    
    /**
     An operation queue for handling history processing tasks: watching changes, deduplicating tags, and triggering UI updates if needed.
     */
    private lazy var historyQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1
        return queue
    }()
    
    /**
     The URL of the thumbnail folder.
     */
    static var attachmentFolder: URL = {
        var url = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("CoreDataCloudKitDemo", isDirectory: true)
        url = url.appendingPathComponent("attachments", isDirectory: true)
        
        // Create it if it doesn’t exist.
        if !FileManager.default.fileExists(atPath: url.path) {
            do {
                try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)

            } catch {
                print("###\(#function): Failed to create thumbnail folder URL: \(error)")
            }
        }
        return url
    }()
    
    init() {
        // Load the last token from the token file.
        if let tokenData = try? Data(contentsOf: tokenFile) {
            do {
                lastHistoryToken = try NSKeyedUnarchiver.unarchivedObject(ofClass: NSPersistentHistoryToken.self, from: tokenData)
            } catch {
                print("###\(#function): Failed to unarchive NSPersistentHistoryToken. Error = \(error)")
            }
        }
    }
}
// MARK: - Notifications

extension CoreDataStack {
    /**
     Handle remote store change notifications (.NSPersistentStoreRemoteChange).
     */
    @objc
    func storeRemoteChange(_ notification: Notification) {
        print("###\(#function): Merging changes from the other persistent store coordinator.")
        
        // Process persistent history to merge changes from other coordinators.
        historyQueue.addOperation {
            self.processPersistentHistory()
        }
    }
}

/**
 Custom notifications in this sample.
 */
extension Notification.Name {
    static let didFindRelevantTransactions = Notification.Name("didFindRelevantTransactions")
}

// MARK: - Persistent history processing

extension CoreDataStack {
    
    /**
     Process persistent history, posting any relevant transactions to the current view.
     */
    func processPersistentHistory() {
        let taskContext = persistentContainer.newBackgroundContext()
        taskContext.performAndWait {
            
            // Fetch history received from outside the app since the last token
            let historyFetchRequest = NSPersistentHistoryTransaction.fetchRequest!
            historyFetchRequest.predicate = NSPredicate(format: "author != %@", appTransactionAuthorName)
            let request = NSPersistentHistoryChangeRequest.fetchHistory(after: lastHistoryToken)
            request.fetchRequest = historyFetchRequest

            let result = (try? taskContext.execute(request)) as? NSPersistentHistoryResult
            guard let transactions = result?.result as? [NSPersistentHistoryTransaction],
                  !transactions.isEmpty
                else { return }

            // Post transactions relevant to the current view.
            DispatchQueue.main.async {
                NotificationCenter.default.post(name: .didFindRelevantTransactions, object: self, userInfo: ["transactions": transactions])
            }

            // Deduplicate the new tags.
            var newTagObjectIDs = [NSManagedObjectID]()
            let tagEntityName = Tag.entity().name

            for transaction in transactions where transaction.changes != nil {
                for change in transaction.changes!
                    where change.changedObjectID.entity.name == tagEntityName && change.changeType == .insert {
                        newTagObjectIDs.append(change.changedObjectID)
                }
            }
            if !newTagObjectIDs.isEmpty {
                deduplicateAndWait(tagObjectIDs: newTagObjectIDs)
            }
            
            // Update the history token using the last transaction.
            lastHistoryToken = transactions.last!.token
        }
    }
}

// MARK: - Deduplicate tags

extension CoreDataStack {
    /**
     Deduplicate tags with the same name by processing the persistent history, one tag at a time, on the historyQueue.
     
     All peers should eventually reach the same result with no coordination or communication.
     */
    private func deduplicateAndWait(tagObjectIDs: [NSManagedObjectID]) {
        // Make any store changes on a background context
        let taskContext = persistentContainer.backgroundContext()
        
        // Use performAndWait because each step relies on the sequence. Since historyQueue runs in the background, waiting won’t block the main queue.
        taskContext.performAndWait {
            tagObjectIDs.forEach { tagObjectID in
                self.deduplicate(tagObjectID: tagObjectID, performingContext: taskContext)
            }
            // Save the background context to trigger a notification and merge the result into the viewContext.
            taskContext.save(with: .deduplicate)
        }
    }

    /**
     Deduplicate a single tag.
     */
    private func deduplicate(tagObjectID: NSManagedObjectID, performingContext: NSManagedObjectContext) {
        guard let tag = performingContext.object(with: tagObjectID) as? Tag,
            let tagName = tag.name else {
            fatalError("###\(#function): Failed to retrieve a valid tag with ID: \(tagObjectID)")
        }

        // Fetch all tags with the same name, sorted by uuid
        let fetchRequest: NSFetchRequest<Tag> = Tag.fetchRequest()
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: Schema.Tag.uuid.rawValue, ascending: true)]
        fetchRequest.predicate = NSPredicate(format: "\(Schema.Tag.name.rawValue) == %@", tagName)
        
        // Return if there are no duplicates.
        guard var duplicatedTags = try? performingContext.fetch(fetchRequest), duplicatedTags.count > 1 else {
            return
        }
        print("###\(#function): Deduplicating tag with name: \(tagName), count: \(duplicatedTags.count)")
        
        // Pick the first tag as the winner.
        let winner = duplicatedTags.first!
        duplicatedTags.removeFirst()
        remove(duplicatedTags: duplicatedTags, winner: winner, performingContext: performingContext)
    }
    
    /**
     Remove duplicate tags from their respective posts, replacing them with the winner.
     */
    private func remove(duplicatedTags: [Tag], winner: Tag, performingContext: NSManagedObjectContext) {
        duplicatedTags.forEach { tag in
            defer { performingContext.delete(tag) }
            guard let posts = tag.posts else { return }
            
            for case let post as Post in posts {
                if let mutableTags: NSMutableSet = post.tags?.mutableCopy() as? NSMutableSet {
                    if mutableTags.contains(tag) {
                        mutableTags.remove(tag)
                        mutableTags.add(winner)
                    }
                }
            }
        }
    }
}
like image 30
malhal Avatar answered Sep 20 '22 13:09

malhal