在不影响CloudKit正确性的情况下执行持久历史记录清除的正确方法是什么?

Che*_*eng 8 core-data ios swift cloudkit

目前,我们正在使用本地CoreData功能CloudKit,通过使用NSPersistentCloudKitContainer.

为什么我们启用持久历史跟踪功能?

由于/sf/answers/5078817971/中描述的问题,我们需要启用NSPersistentHistoryTrackingKey.


清除历史记录

基于https://developer.apple.com/documentation/coredata/consuming_relevant_store_changes,我们应该手动执行持久历史记录清除。


但是,目前尚不完全清楚我们如何以安全的方式清除历史记录,而不影响 的正确性CloudKit。我们倾向于使用以下设置运行一些测试。

  1. 运行模拟器。我们将在模拟器中执行插入操作
  2. 运行真实设备。由于步骤 1,真实设备将收到静默推送通知。
  3. 模拟器和真实设备都运行相同的代码。
  4. 每当我们在模拟器中插入一个项目时,我们都会观察真实设备中发生的情况。

测试1:处理后立即清除所有历史数据

@objc func storeRemoteChange(_ notification: Notification) {
    // Process persistent history to merge changes from other coordinators.
    historyQueue.addOperation {
        self.processPersistentHistory()
    }
}

/**
 Process persistent history, posting any relevant transactions to the current view.
 */
private func processPersistentHistory() {
    backgroundContext.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? backgroundContext.execute(request)) as? NSPersistentHistoryResult
        guard let transactions = result?.result as? [NSPersistentHistoryTransaction] else { return }

        ...
        
        // Update the history token using the last transaction.
        lastHistoryToken = transactions.last!.token
        
        // Remove history before the last history token
        let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: lastHistoryToken)
        do {
            try backgroundContext.execute(purgeHistoryRequest)
        } catch {
            error_log(error)
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

我们的观察是,真实设备获取了错误的CloudKit同步信息。真实设备要么获取重复的数据,要么其数据被删除。

我们对这个问题的假设是

  1. 持久性历史数据在多个持久性协调器之间共享。
  2. 我们可见的协调员已完成事务处理,在 中标记一条记录lastHistoryToken,然后清除所有早于 的历史记录lastHistoryToken。
  3. 然而,还有另一个不可见的协调器,用于CloudKit同步。协调器很有可能CloudKit尚未处理已删除的历史交易。
  4. 这会导致所有数据出错,当CloudKit倾向于同步真实设备数据时,没有必要的交易历史记录。

测试2:处理后清除所有超过2分钟的历史数据

我们对上面的代码进行了微调,仅删除超过 2 分钟的交易历史记录。

// Remove history older than 2 minutes.
let date = Date(timeMillis: Date.currentTimeMillis - 2*60*1000)
let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
do {
    try backgroundContext.execute(purgeHistoryRequest)
} catch {
    error_log(error)
}
Run Code Online (Sandbox Code Playgroud)

我们的观察是

  1. storeRemoteChange如果最后一次触发与当前触发的时间storeRemoteChange差小于2分钟,真实设备将获得正确的CloudKit同步信息。
  2. 如果上次storeRemoteChange触发与当前触发的时间差storeRemoteChange超过 2 分钟,真实设备将获取错误的CloudKit 同步信息。真实设备要么获取重复的数据,要么其数据被删除。

总结与问题

基于如何在 CoreData+CloudKit 应用程序中修剪历史记录?

作者建议

因此,在处理持久性历史记录(例如 7 天后)后对其进行修剪确实是安全的。

适用于 1 个用户、2 个设备的情况。

  1. 用户倾向于在他经常使用的设备 A 上频繁地读/写。
  2. 自上次在设备 B 上使用以来 7 天后,用户将在其很少使用的设备 B 上启动相同的应用程序。

这是否意味着设备 B 将获得错误的 CloudKit 同步信息?(根据测试 2 的观察,似乎是的)

如果是,在不影响CloudKit正确性的情况下执行持久历史记录清除的好方法是什么?


我如何运行测试 2?

您可以通过以下方式设置并运行测试 2

  1. 从https://developer.apple.com/documentation/coredata/synchronizing_a_local_store_to_the_cloud设置并运行示例
  2. 替换CoreDataStack.swift为https://gist.github.com/yccheok/df21f199b81b19764ffbcd4a4583c430。它包含 的辅助函数Date以及 2 分钟历史记录清除代码。
  3. 在模拟器中,点击右上角创建 1 条记录。您可以观察到真实设备现在有 1 条记录。
  4. 3分钟后,再次点击右上角。在模拟器中,可以观察到总共有2条记录。然而,在真实设备中,数据消失了!

在此输入图像描述 (图中左侧设备为真实设备,右侧设备为模拟器)

Rei*_*ner 7

更新: 对于 CoreData + ClouKit 设置特别重要

\n

在WWDC22 核心数据实验室的这篇文章中,Apple 核心数据框架工程师回答了“我是否需要清除持久历史跟踪数据? ”的问题,如下所示:

\n
\n

No. We don\xe2\x80\x99t recommend it. NSPersistentCloudKitContainer uses the\npersistent history token to track what to sync. If you delete history\nthe cloud sync is reset and has to upload everything from scratch. It\nwill recover but it\xe2\x80\x99s not a good customer experience. It shouldn\xe2\x80\x99t\nnormally be necessary to delete history. For example, the Apple Photos\napp doesn\xe2\x80\x99t trim its history, so unless you\xe2\x80\x99re generating massive\namounts of history don\xe2\x80\x99t do it.

\n
\n

tl;dr:

\n

It seems that purging the persistent history after 7 days works in almost all cases.
\nIt probably does not, if GBs of data have to be synced.

\n

What I did:

\n

I could reproduce the error:
\nIf in Apple\' demo app data are synced after the persistent history is purged, wrong data may be displayed. Apparently some info has been deleted that is essential for the demo app.

\n

Below, I started testing with a clean setup:
\nI deleted the app from simulator and device, and cleared all CD_Post records in the iCloud private database, zone com.apple.coredata.cloudkit.zone, using the dashboard.
\nTo check for info that might have been deleted unintentionally, I inserted in func processPersistentHistory() a print statement in the guard statement that filters the persistent history for transactions:

\n
guard let transactions = result?.result as? [NSPersistentHistoryTransaction],\n      !transactions.isEmpty\n      else {\n        print("**************** \\(String(describing: result?.result))")\n        return\n      }  \n
Run Code Online (Sandbox Code Playgroud)\n

If I run the app on the simulator under Xcode, no entries were shown as expected, and the log shows now many such entries:

\n
**************** Optional(<__NSArray0 0x105a61900>(\n\n)\n)  \n
Run Code Online (Sandbox Code Playgroud)\n

Apparently the persistent history contains iCloud mirroring housekeeping information that is deleted when the persistent history is purged. This indicates to me that the mirroring software needs "enough time" to finish its operation successfully, and thus only "old" history entries should be purged. But what is "old"? 7 days?

\n

Next, on the simulator under Xcode, I installed and executed the app with immediate purging as in Test 1 of the question.

\n
// Remove history before the last history token\nlet purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: lastHistoryToken)\ndo {\n  try taskContext.execute(purgeHistoryRequest)\n} catch {\n  print("\\(error)")\n}\n
Run Code Online (Sandbox Code Playgroud)\n

On the simulator, I added an entry. This entry was shown in the dashboard.

\n

Then, on the device under Xcode, I also installed and executed the app with immediate purging. The entry was correctly shown, i.e. the iCloud record was mirrored to the persistent store of the device, the history was processed and immediately purged, although, maybe, the the mirroring software did not have "enough time" to finish its operation successfully.

\n

On the simulator, I added a 2nd entry. This entry was also shown in the dashboard.

\n

However, on the device the 1st entry disappeared, i.e. the table was now empty, but both entries were still shown in the dashboard, i.e. the iCloud data were not corrupted.

\n

I then set a breakpoint at DispatchQueue.main.async of func processPersistentHistory(). This breakpoint is only reached when a remote change of the persistent store is processed. To reach the breakpoint in the device, I added a 3rd entry in the simulator. Thus the breakpoint was reached in the device, and in the debugger I entered

\n
(lldb) po taskContext.fetch(Post.fetchRequest())  \n\xe2\x96\xbf 3 elements\n  - 0 : <Post: 0x281400910> (entity: Post; id: 0xbc533cc5eb8b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p3>; data: <fault>)\n  - 1 : <Post: 0x281403d90> (entity: Post; id: 0xbc533cc5eb6b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p4>; data: <fault>)\n  - 2 : <Post: 0x281403390> (entity: Post; id: 0xbc533cc5eb4b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p5>; data: <fault>)\n
Run Code Online (Sandbox Code Playgroud)\n

This indicates to me that the persistent store in the device has correct data, and only the displayed table is wrong.

\n

Next I investigated func update in the MainViewController. This function is called from func didFindRelevantTransactions, which is called when history is processed, and relevant transactions are posted. During my tests, transactions.count is always <= 10, so the transactions are processed in the block transactions.forEach.
\nI tried to find out what NSManagedObjectContext.mergeChanges does. Thus I modified the code as

\n
transactions.forEach { transaction in\n  guard let userInfo = transaction.objectIDNotification().userInfo else { return }\n  let viewContext = dataProvider.persistentContainer.viewContext\n  print("BEFORE: \\(dataProvider.fetchedResultsController.fetchedObjects!)")\n  print("================ mergeChanges: userInfo: \\(userInfo)")\n  NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [viewContext])\n  print("AFTER: \\(dataProvider.fetchedResultsController.fetchedObjects!)")\n}  \n
Run Code Online (Sandbox Code Playgroud)\n

To see, what happens to the viewContext, I implemented

\n
@objc func managedObjectContextObjectsDidChange(notification: NSNotification) {\n  guard let userInfo = notification.userInfo else { return }\n  print(#function, userInfo)  \n}\n
Run Code Online (Sandbox Code Playgroud)\n

and to see how this influences the fetchedResultsController, I implemented also

\n
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, \n                didChange anObject: Any, \n                at indexPath: IndexPath?, \n                for type: NSFetchedResultsChangeType, \n                newIndexPath: IndexPath?) {\n  print("**************** ", #function, "\\(type) ", anObject)\n}  \n
Run Code Online (Sandbox Code Playgroud)\n

To keep the logs relatively short, I deleted in the dashboard all CD_Post entries except the 1st one, and deleted the app from the simulator ans the device.
\nI then run, under Xcode, the app on the simulator and the device. Both show the 1st entry.

\n

I then entered another entry in the simulator. As unfortunately expected, the table on the device was cleared. Here is the log of the device:

\n
BEFORE: [<Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {\n    attachments =     (\n    );\n    content = nil;\n    location = nil;\n    tags =     (\n    );\n    title = "Untitled 3:40:24 PM";\n}), <Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: <fault>)]\n================ mergeChanges: userInfo: [AnyHashable("deleted_objectIDs"): {(\n    0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>,\n    0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>\n)}]\nmanagedObjectContextObjectsDidChange(notification:) [AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a8100>, AnyHashable("deleted"): {(\n    <Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: {\n    attachments =     (\n    );\n    content = nil;\n    location = nil;\n    tags =     (\n    );\n    title = nil;\n}),\n    <Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {\n    attachments =     (\n    );\n    content = nil;\n    location = nil;\n    tags =     (\n    );\n    title = "Untitled 3:40:24 PM";\n})\n)}, AnyHashable("NSObjectsChangedByMergeChangesKey"): {(\n)}]\n****************  controller(_:didChange:at:for:newIndexPath:) NSFetchedResultsChangeType(rawValue: 2)  <Post: 0x2802d2a80> (entity: Post; id: 0x9aac7c6d195c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p2>; data: {\n    attachments =     (\n    );\n    content = nil;\n    location = nil;\n    tags =     (\n    );\n    title = nil;\n})\n****************  controller(_:didChange:at:for:newIndexPath:) NSFetchedResultsChangeType(rawValue: 2)  <Post: 0x2802c2d50> (entity: Post; id: 0x9aac7c6d193c7772 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/Post/p1>; data: {\n    attachments =     (\n    );\n    content = nil;\n    location = nil;\n    tags =     (\n    );\n    title = "Untitled 3:40:24 PM";\n})\nmanagedObjectContextObjectsDidChange(notification:) [AnyHashable("updated"): {(\n    <NSCKRecordZoneMetadata: 0x2802ce9e0> (entity: NSCKRecordZoneMetadata; id: 0x9aac7c6d193c77d2 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordZoneMetadata/p1>; data: {\n    ckOwnerName = "__defaultOwner__";\n    ckRecordZoneName = "com.apple.coredata.cloudkit.zone";\n    currentChangeToken = "<CKServerChangeToken: 0x2823fcdc0; data=AQAAAAAAAACQf/////////+gT9nZvOBLv7hsIaI3NVdg>";\n    database = "0x9aac7c6d193c77e2 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKDatabaseMetadata/p1>";\n    encodedShareData = nil;\n    hasRecordZoneNum = 1;\n    hasSubscriptionNum = 0;\n    lastFetchDate = "2022-06-15 13:55:25 +0000";\n    mirroredRelationships = "<relationship fault: 0x2821a3c60 \'mirroredRelationships\'>";\n    needsImport = 0;\n    needsRecoveryFromIdentityLoss = 0;\n    needsRecoveryFromUserPurge = 0;\n    needsRecoveryFromZoneDelete = 0;\n    needsShareDelete = 0;\n    needsShareUpdate = 0;\n    queries = "<relationship fault: 0x2821a2560 \'queries\'>";\n    records =     (\n    );\n    supportsAtomicChanges = 1;\n    supportsFetchChanges = 1;\n    supportsRecordSharing = 1;\n    supportsZoneSharing = 1;\n})\n)}, AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a1900>, AnyHashable("deleted"): {(\n    <NSCKRecordMetadata: 0x2802ce850> (entity: NSCKRecordMetadata; id: 0x9aac7c6d193c7762 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordMetadata/p1>; data: {\n    ckRecordName = "3FB952E5-6B30-472E-BC6E-0116FA507B88";\n    ckRecordSystemFields = nil;\n    ckShare = nil;\n    encodedRecord = "{length = 50, bytes = 0x6276786e f7090000 52070000 e0116270 ... 61726368 69000ee0 }";\n    entityId = 3;\n    entityPK = 1;\n    lastExportedTransactionNumber = nil;\n    moveReceipts =     (\n    );\n    needsCloudDelete = 0;\n    needsLocalDelete = 0;\n    needsUpload = 0;\n    pendingExportChangeTypeNumber = nil;\n    pendingExportTransactionNumber = nil;\n    recordZone = nil;\n}),\n    <NSCKRecordMetadata: 0x2802cdcc0> (entity: NSCKRecordMetadata; id: 0x9aac7c6d195c7762 <x-coredata://496D2B54-DDB9-47EF-945A-CC1DBA1E14E8/NSCKRecordMetadata/p2>; data: {\n    ckRecordName = "0919480D-16CB-49F9-8351-9471371040AC";\n    ckRecordSystemFields = nil;\n    ckShare = nil;\n    encodedRecord = "{length = 50, bytes = 0x6276786e f7090000 52070000 e0116270 ... 61726368 69000ee0 }";\n    entityId = 3;\n    entityPK = 2;\n    lastExportedTransactionNumber = nil;\n    moveReceipts =     (\n    );\n    needsCloudDelete = 0;\n    needsLocalDelete = 0;\n    needsUpload = 0;\n    pendingExportChangeTypeNumber = nil;\n    pendingExportTransactionNumber = nil;\n    recordZone = nil;\n})\n)}]\nmanagedObjectContextObjectsDidChange(notification:) [AnyHashable("managedObjectContext"): <_PFWeakReference: 0x2821a3060>, AnyHashable("invalidatedAll"): <__NSArrayM 0x282f75830>(\n\n)\n]\nAFTER: []  \n
Run Code Online (Sandbox Code Playgroud)\n

This indicates to me:

\n
    \n
  • Before NSManagedObjectContext.mergeChanges, the table was correct, i.e. it contained both posts p1 & p2.
  • \n
  • Merging was done again with both posts.
  • \n
  • In the viewContext, both posts were deleted (AnyHashable("deleted")).
  • \n
  • The fetchedResultsController responded by deleting both posts also (NSFetchedResultsChangeType(rawValue: 2)).
  • \n
  • Eventually it is logged that the fetchedResultsController has no objects, and thus the table is empty.
  • \n
\n

As a final check, I out commented in func processPersistentHistory() the code that purges the history, and as expected, the table was displayed correctly, also when I entered another entry in the simulator.

\n

What are the conclusions?

\n
    \n
  • On both persistent stores (simulator & device), and in iCloud, all data were always correct.
  • \n
  • Merging of remote store changes to a context fails, if the mirroring software does not have enough time to process its entries in the persistent history.
  • \n
  • How long this takes depends probably on the amount of data that has to be synced. My experience is that some kb take some seconds, but this depends of course on many parameters. But if so, 7 days correspond to some GB to sync, which is rather unusual. In this respect, purging the persistent history after 7 days seems to be a good compromise between memory consumption and correct app operation.
  • \n
\n

Further hints to reproduce the tests (this may help others who try the same):

\n

As suggested, I downloaded Apple\'s demo app and the core data stack modified by you.
\nIt did compile for a simulator, but for the device I had to set 3 additional settings in the Signing & Capabilities tab of the target:

\n
    \n
  • Set the development team
  • \n
  • Set the bundle identifier to a reasonable value, e.g. com.<your company>.CoreDataCloudKitDemo.
  • \n
  • Select the right iCloud container, e.g. iCloud.com.<your company>.CoreDataCloudKitDemo.
  • \n
  • Additionally I had to ensure that the simulator and the device were logged in to the same iCloud account. Note that for the simulator, one has to re-log in about once a day. Mostly one is reminded to do so, but sometimes not.
  • \n
\n

Then, I could run the app on the simulator and the device.
\nI verified in the CloudKit Console that in the Private Database, zone com.apple.coredata.cloudkit.zone there are no records of type CD_Post. Since data are not shared, the iCloud Sharing database is not used.

\n