【问题标题】:What is the right way to perform persistent history purging, without affecting the correctness of CloudKit?在不影响 CloudKit 正确性的情况下,执行持久性历史清除的正确方法是什么?
【发布时间】:2022-06-15 22:49:02
【问题描述】:

目前,我们通过使用NSPersistentCloudKitContainer 来使用具有CloudKit 功能的本地CoreData

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

由于https://stackoverflow.com/a/72554542/72437中描述的问题,我们需要启用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)
        }
    }
}

我们的观察是,真实设备出现错误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)
}

我们的观察是

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

总结和问题

基于How to prune history right in a CoreData+CloudKit app?

作者建议

所以在说七之后修剪持久的历史确实是安全的 处理后的几天。

对于 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 条记录。但是,在真实设备中,数据消失了!

(图中左侧为真机,右侧为模拟器)

【问题讨论】:

  • 我正在调查情况。现在我可以重现你的问题。更多(希望)稍后。

标签: ios swift core-data cloudkit


【解决方案1】:

tl;博士:

似乎在 7 天后清除持久历史记录几乎在所有情况下都有效。
如果必须同步 GB 的数据,它可能不会。

我做了什么:

我可以重现错误:
如果在 Apple 的演示应用程序中,在清除持久历史记录后同步数据,则可能会显示错误的数据。显然,一些对演示应用至关重要的信息已被删除。

下面,我开始使用干净的设置进行测试:
我从模拟器和设备中删除了该应用程序,并使用仪表板清除了 iCloud 私人数据库区域 com.apple.coredata.cloudkit.zone 中的所有 CD_Post 记录。
为了检查可能被无意删除的信息,我在 func processPersistentHistory() 中插入了一个 print 语句,用于过滤交易的持久历史记录:

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

如果我在 Xcode 下的模拟器上运行该应用程序,则没有按预期显示任何条目,并且日志现在显示了许多这样的条目:

**************** Optional(<__NSArray0 0x105a61900>(

)
)  

显然,持久性历史记录包含 iCloud 镜像内务管理信息,这些信息会在持久性历史记录被清除时被删除。这向我表明,镜像软件需要“足够的时间”才能成功完成其操作,因此应该只清除“旧”历史条目。但什么是“老”? 7 天?

接下来,在 Xcode 下的模拟器上,我安装并执行了应用程序,并像问题的测试 1 一样立即清除。

// Remove history before the last history token
let purgeHistoryRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: lastHistoryToken)
do {
  try taskContext.execute(purgeHistoryRequest)
} catch {
  print("\(error)")
}

在模拟器上,我添加了一个条目。此条目显示在仪表板中。

然后,在 Xcode 下的设备上,我还安装并执行了立即清除的应用程序。该条目已正确显示,即 iCloud 记录已镜像到设备的永久存储,历史记录已被处理并立即清除,尽管镜像软件可能没有“足够的时间”成功完成其操作。

在模拟器上,我添加了第二个条目。此条目也显示在仪表板中。

但是,在设备上第一个条目消失了,即表格现在是空的,但两个条目仍显示在仪表板中,即 iCloud 数据没有损坏.

然后我在DispatchQueue.main.asyncfunc processPersistentHistory() 处设置一个断点。只有在处理持久存储的远程更改时才会到达此断点。为了到达设备中的断点,我在模拟器中添加了第三个条目。因此在设备中达到断点,在我进入的调试器中

(lldb) po taskContext.fetch(Post.fetchRequest())  
▿ 3 elements
  - 0 : <Post: 0x281400910> (entity: Post; id: 0xbc533cc5eb8b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p3>; data: <fault>)
  - 1 : <Post: 0x281403d90> (entity: Post; id: 0xbc533cc5eb6b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p4>; data: <fault>)
  - 2 : <Post: 0x281403390> (entity: Post; id: 0xbc533cc5eb4b892a <x-coredata://C9DEC274-B479-4AF5-9349-76C1BABB5016/Post/p5>; data: <fault>)

这向我表明设备中的持久存储有正确的数据,只有显示的表格是错误的

接下来我调查了MainViewController中的func update。这个函数是从func didFindRelevantTransactions调用的,在处理历史,并过帐相关交易时调用。在我的测试中,transactions.count 总是 transactions.forEach 中处理。
我试图找出NSManagedObjectContext.mergeChanges 的作用。因此我将代码修改为

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

为了看看,viewContext 发生了什么,我实现了

@objc func managedObjectContextObjectsDidChange(notification: NSNotification) {
  guard let userInfo = notification.userInfo else { return }
  print(#function, userInfo)  
}

为了看看这如何影响fetchedResultsController,我也实现了

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

为了保持日志相对较短,我在仪表板中删除了除第一个以外的所有 CD_Post 条目,并从模拟器和设备中删除了该应用程序。
然后我在 Xcode 下运行模拟器和设备上的应用程序。两者都显示第一个条目。

然后我在模拟器中输入了另一个条目。正如所料,设备上的桌子被清空了。这是设备的日志:

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

)
]
AFTER: []  

这向我表明:

  • NSManagedObjectContext.mergeChanges 之前的表格是正确的,即它包含帖子 p1 和 p2。
  • 再次合并两个帖子。
  • viewContext 中的两个帖子均已删除 (AnyHashable("deleted"))。
  • fetchedResultsController 回复同时删除了这两个帖子 (NSFetchedResultsChangeType(rawValue: 2))。
  • 最终记录到fetchedResultsController 没有对象,因此表为空。

作为最后的检查,我在func processPersistentHistory() 中注释掉了清除历史记录的代码,并且正如预期的那样,表格显示正确,当我在模拟器中输入另一个条目时也是如此。

结论是什么?

  • 在持久存储(模拟器和设备)以及 iCloud 中,所有数据始终正确。
  • 如果镜像软件没有足够的时间来处理其在持久历史记录中的条目,则将远程存储更改合并到上下文会失败。
  • 这需要多长时间可能取决于必须同步的数据量。我的经验是一些 kb 需要几秒钟,但这当然取决于许多参数。但如果是这样,7 天对应于一些 GB 同步,这是相当不寻常的。在这方面,在 7 天后清除持久性历史记录似乎是内存消耗和正确应用运行之间的良好折衷。

重现测试的更多提示(这可能对尝试相同的其他人有所帮助):

按照建议,我下载了 Apple 的演示应用程序和您修改的核心数据堆栈。
它确实为模拟器编译,但对于设备,我必须在目标的 Signing & Capabilities 选项卡中设置 3 个附加设置:

  • 设置开发团队
  • 将包标识符设置为合理的值,例如com.&lt;your company&gt;.CoreDataCloudKitDemo
  • 选择正确的 iCloud 容器,例如iCloud.com.&lt;your company&gt;.CoreDataCloudKitDemo
  • 此外,我必须确保模拟器和设备登录到同一个 iCloud 帐户。请注意,对于模拟器,必须每天重新登录一次。大多数情况下会提醒您这样做,但有时不会。

然后,我可以在模拟器和设备上运行该应用程序。
我在 CloudKit 控制台中验证,在私有数据库区域 com.apple.coredata.cloudkit.zone 中没有 CD_Post 类型的记录。由于不共享数据,因此不使用 iCloud 共享数据库。

【讨论】:

    猜你喜欢
    • 2012-11-29
    • 2019-11-09
    • 1970-01-01
    • 2021-08-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-04-23
    相关资源
    最近更新 更多