Persistent History Tracking in Core Data

WWDC 2017 introduced a new concept available from iOS 11 which is persistent history tracking. It’s Apple’s answer for merging changes that come from several targets like app extensions. Whenever you change something in your Core Data database from your Share Extension, a transaction is written which can be merged into any of your other targets.

You might have read my article Core Data and App extensions: Sharing a single database which is suggesting Darwin Notifications to share a database between app extensions. This has been a great solution and still works but isn’t recommended as a solution by Apple and uses undocumented Darwin Notifications. Therefore, I would highly recommend switching over to persistent history tracking if you’ve used Darwin Notifications up until today.

How does persistent history tracking work?

With Persistent History Tracking enabled your app will start writing transactions for any changes that occur in your Core Data store. Whether they happen from an app extension, background context, or your main app, they’re all written into transactions.

Each of your app’s targets can fetch transactions that happened since a given date and merge those into their local store. This way, you can keep your store up to date with changes from other coordinators. After you’ve merged all transactions you can update the merged date so you’ll only fetch new transactions on the next merge.

Remote Change Notifications

Although introduced in iOS 11 it’s best to implement this feature using Remote Change Notifications which are introduced in iOS 13. This allows you to only fetch new transactions when a remote change occurred. Up until this new feature was introduced we had to do a poll for changes whenever that fits, for example, during a didBecomeActive event. Fetching for transactions is expensive so it’s much better to do this when there’s actually a new transaction to fetch.

During this post you’ll see several performance improvements to narrow down the amount of work that needs to be done. Only relevant transactions are fetched and only at times that it makes sense.

If you want to learn more about persistent history tracking I also recommended watching the WWDC 2017 What’s New in Core Data session in which Apple announces persistent history tracking.

How to enable persistent history tracking

As discussed we’re going to enable both persistent history tracking and remote notifications. We can do this by setting persistent store options in our NSPersistentStoreDescription which we use as input for our NSPersistentContainer.

let storeDescription = NSPersistentStoreDescription()
storeDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

let persistentContainer = NSPersistentContainer(name: "SwiftLee_Persistent_Container")
persistentContainer.persistentStoreDescriptions = [storeDescription]

persistentContainer.loadPersistentStores(completionHandler: { storeDescription, error in
    guard error == nil else {
        assertionFailure("Persistent store '\(storeDescription)' failed loading: \(String(describing: error))")
        return
    }
})

Note that we’ll have to set these options before we instruct the persistent container to load its persistent stores. Otherwise, both options won’t have any effect.

Listening for Remote Change Notifications

Once you’ve configured your persistent container for writing data transactions you can start observing for remote change notifications. It’s best to create a dedicated instance for this so we keep our code separated.

Before we dive into that code we start by defining an enum which defines all our targets. In this example, we have an app and a share extension:

public enum AppTarget: String, CaseIterable {
    case app
    case shareExtension
}

With this app target enum we can initiate a PersistentHistoryObserver which is responsible for observing remote changes:

public final class PersistentHistoryObserver {

    private let target: AppTarget
    private let userDefaults: UserDefaults
    private let persistentContainer: NSPersistentContainer

    /// An operation queue for processing history transactions.
    private lazy var historyQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1
        return queue
    }()

    public init(target: AppTarget, persistentContainer: NSPersistentContainer, userDefaults: UserDefaults) {
        self.target = target
        self.userDefaults = userDefaults
        self.persistentContainer = persistentContainer
    }

    public func startObserving() {
        NotificationCenter.default.addObserver(self, selector: #selector(processStoreRemoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
    }

    /// Process persistent history to merge changes from other coordinators.
    @objc private func processStoreRemoteChanges(_ notification: Notification) {
        historyQueue.addOperation { [weak self] in
            self?.processPersistentHistory()
        }
    }

    @objc private func processPersistentHistory() {
        let context = persistentContainer.newBackgroundContext()
        context.performAndWait {
            do {
                /// .. Perform merge
                /// .. Perform clean up
            } catch {
                print("Persistent History Tracking failed with error \(error)")
            }
        }
    }
}

The processPersistentHistory method is yet to be implemented and will be responsible for our business logic regarding merging and cleaning up of persistent history transactions.

We’re using a background context to not block the main queue with our view context. We also make use of a serial operation queue so we don’t end up handling transactions twice due to race conditions. You can learn more about operation queues in my blog post Getting started with Operations and OperationQueues in Swift.

Merging persistent history transactions into your app targets

The processing of transactions is done in two steps:

  • Transactions are fetched and merging into the local store
  • The transactions that are merged into each target are cleaned up

We start by implementing an instance which is responsible for fetching the transactions and merging them into the local store.

This instance will make use of several convenience methods available on UserDefaults after implementing the following extension methods:

extension UserDefaults {

    func lastHistoryTransactionTimestamp(for target: AppTarget) -> Date? {
        let key = "lastHistoryTransactionTimeStamp-\(target.rawValue)"
        return object(forKey: key) as? Date
    }

    func updateLastHistoryTransactionTimestamp(for target: AppTarget, to newValue: Date?) {
        let key = "lastHistoryTransactionTimeStamp-\(target.rawValue)"
        set(newValue, forKey: key)
    }

    func lastCommonTransactionTimestamp(in targets: [AppTarget]) -> Date? {
        let timestamp = targets
            .map { lastHistoryTransactionTimestamp(for: $0) ?? .distantPast }
            .min() ?? .distantPast
        return timestamp > .distantPast ? timestamp : nil
    }
}

These methods allow us to fetch the common transaction timestamp between targets as well as to read and write a timestamp per given target.

Creating a persistent history merger

The following instance is responsible for fetching the transactions for the current target, merging them into the local store, and updating the timestamp for the current active app target.

struct PersistentHistoryMerger {

    let backgroundContext: NSManagedObjectContext
    let viewContext: NSManagedObjectContext
    let currentTarget: AppTarget
    let userDefaults: UserDefaults

    func merge() throws {
        let fromDate = userDefaults.lastHistoryTransactionTimestamp(for: currentTarget) ?? .distantPast
        let fetcher = PersistentHistoryFetcher(context: backgroundContext, fromDate: fromDate)
        let history = try fetcher.fetch()

        guard !history.isEmpty else {
            print("No history transactions found to merge for target \(currentTarget)")
            return
        }

        print("Merging \(history.count) persistent history transactions for target \(currentTarget)")

        history.merge(into: backgroundContext)

        viewContext.perform {
            history.merge(into: self.viewContext)
        }

        guard let lastTimestamp = history.last?.timestamp else { return }
        userDefaults.updateLastHistoryTransactionTimestamp(for: currentTarget, to: lastTimestamp)
    }
}

This merger follows a few steps:

  • It fetches all transactions that are relevant for the current target by using the current from date
  • The transactions are merged into the background context and directly after into the view context so that both contexts are up to date
  • The last merged history transaction timestamp is updated so that we don’t merge the same transactions again

The merger makes use of a convenience method for merging transactions into a given context:

extension Collection where Element == NSPersistentHistoryTransaction {

    /// Merges the current collection of history transactions into the given managed object context.
    /// - Parameter context: The managed object context in which the history transactions should be merged.
    func merge(into context: NSManagedObjectContext) {
        forEach { transaction in
            guard let userInfo = transaction.objectIDNotification().userInfo else { return }
            NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
        }
    }
}

Creating a transactions fetcher

Most of the optimized logic is found within a dedicated instance for fetching transactions. This fetcher tries to optimise the fetch request used for getting all transactions after the given from date by filtering on the view context name and transaction author:

struct PersistentHistoryFetcher {

    enum Error: Swift.Error {
        /// In case that the fetched history transactions couldn't be converted into the expected type.
        case historyTransactionConvertionFailed
    }

    let context: NSManagedObjectContext
    let fromDate: Date

    func fetch() throws -> [NSPersistentHistoryTransaction] {
        let fetchRequest = createFetchRequest()

        guard let historyResult = try context.execute(fetchRequest) as? NSPersistentHistoryResult, let history = historyResult.result as? [NSPersistentHistoryTransaction] else {
            throw Error.historyTransactionConvertionFailed
        }

        return history
    }

    func createFetchRequest() -> NSPersistentHistoryChangeRequest {
        let historyFetchRequest = NSPersistentHistoryChangeRequest
            .fetchHistory(after: fromDate)

        if let fetchRequest = NSPersistentHistoryTransaction.fetchRequest {
            var predicates: [NSPredicate] = []

            if let transactionAuthor = context.transactionAuthor {
                /// Only look at transactions created by other targets.
                predicates.append(NSPredicate(format: "%K != %@", #keyPath(NSPersistentHistoryTransaction.author), transactionAuthor))
            }
            if let contextName = context.name {
                /// Only look at transactions not from our current context.
                predicates.append(NSPredicate(format: "%K != %@", #keyPath(NSPersistentHistoryTransaction.contextName), contextName))
            }

            fetchRequest.predicate = NSCompoundPredicate(type: .and, subpredicates: predicates)
            historyFetchRequest.fetchRequest = fetchRequest
        }

        return historyFetchRequest
    }
}

To make sure that this code works as expected it’s important to update your managed object contexts with a name and transaction author:

persistentContainer.viewContext.name = "view_context"
persistentContainer.viewContext.transactionAuthor = "main_app"

This allows you to identify the context and target that created a certain transaction and also allows the optimisation to work. Only transactions that are not created by the given context are fetched and merged so that we wont merge in transactions that already exist in our context.

This completes our merging logic and allows us to stay up to date with remote changes. Up next is making sure we clean up nicely once a transaction is merged into each existing target.

Cleaning up persistent history transactions

All transactions are cached on disk and will take up space. As a good citizen of the platform, we need to clean up old transactions and free up space.

We can do this by fetching all transactions that are merged into each active target. Once a transaction is merged into each target it’s no longer valuable to keep around and it’s fine if we delete it.

Once again, we’ll create a dedicated instance to do so:

struct PersistentHistoryCleaner {

    let context: NSManagedObjectContext
    let targets: [AppTarget]
    let userDefaults: UserDefaults

    /// Cleans up the persistent history by deleting the transactions that have been merged into each target.
    func clean() throws {
        guard let timestamp = userDefaults.lastCommonTransactionTimestamp(in: targets) else {
            print("Cancelling deletions as there is no common transaction timestamp")
            return
        }

        let deleteHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: timestamp)
        print("Deleting persistent history using common timestamp \(timestamp)")
        try context.execute(deleteHistoryRequest)

        targets.forEach { target in
            /// Reset the dates as we would otherwise end up in an infinite loop.
            userDefaults.updateLastHistoryTransactionTimestamp(for: target, to: nil)
        }
    }
}

This cleaner instance performs in a few steps:

  • A common timestamp is determined. If a common timestamp doesn’t exist we still have transactions that need to be merged into one of our targets. Therefore, we need to cancel the deletion process
  • The common date is used to delete the history
  • Each target’s transaction timestamp is reset so that we don’t end up deleting history in an infinite loop

This completes our logic for implementing persistent history tracking by calling those instances in our earlier defined PersistentHistoryObserver:

@objc private func processPersistentHistory() {
    let context = persistentContainer.newBackgroundContext()
    context.performAndWait {
        do {
            let merger = PersistentHistoryMerger(backgroundContext: context, viewContext: persistentContainer.viewContext, currentTarget: target, userDefaults: userDefaults)
            try merger.merge()

            let cleaner = PersistentHistoryCleaner(context: context, targets: AppTarget.allCases, userDefaults: userDefaults)
            try cleaner.clean()
        } catch {
            print("Persistent History Tracking failed with error \(error)")
        }
    }
}

And that’s it. Your app stays up to date and merges any incoming transactions from remote app targets into your local database.

Conclusion

Persistent history tracking is a great solution built by Apple. It keeps both your main app and its extensions up to date with a single database. It takes a transaction merger and cleaner to make sure transactions are handled correctly.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!