Repository navigation
fix(claude): migrate email-keyed iCloud snapshots to slot keys #3111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
dc440cf
7080d1b
5dc264d
ec188c2
eac53a8
7f93015
290cfee
7e663ae
d400c40
7d75b12
a4b052d
535a747
f491337
dbc3445
aff7c19
9d6567f
4ee82a8
cd69625
78d553b
ecb286e
0e29fc9
3d0ffb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -150,6 +150,132 @@ enum CloudSyncDirtyState { | |
| } | ||
| } | ||
|
|
||
| enum CloudSyncSnapshotMigration { | ||
| static func obsoleteRecordNames( | ||
| liveSnapshots: [AccountSnapshotSyncPayload], | ||
| hashes: [String: String], | ||
| envelope: CloudSyncPersistence.Envelope) -> Set<String> | ||
| { | ||
| AccountSnapshotSyncPayload.obsoleteEmailKeyedRecordNames( | ||
| liveSnapshots: liveSnapshots, | ||
| knownRecordNames: Set(hashes.keys).union(envelope.fleetSnapshots.keys)) | ||
| } | ||
|
|
||
| static func drop( | ||
| _ names: Set<String>, | ||
| hashes: inout [String: String], | ||
| envelope: inout CloudSyncPersistence.Envelope, | ||
| desiredRecords: inout [CKRecord.ID: CKRecord], | ||
| zoneID: CKRecordZone.ID) -> [CKRecord.ID] | ||
| { | ||
| names.map { name in | ||
| let recordID = CKRecord.ID(recordName: name, zoneID: zoneID) | ||
| desiredRecords.removeValue(forKey: recordID) | ||
| hashes.removeValue(forKey: name) | ||
| envelope.fleetSnapshots.removeValue(forKey: name) | ||
| envelope.encodedSystemFields.removeValue(forKey: name) | ||
| envelope.recordMetadata.removeValue(forKey: name) | ||
| return recordID | ||
| } | ||
| } | ||
|
|
||
| static func predecessorNames( | ||
| for snapshot: AccountSnapshotSyncPayload, | ||
| obsoleteNames: Set<String>) -> Set<String> | ||
| { | ||
| guard let predecessor = snapshot.emailKeyedPredecessorRecordName(), | ||
| obsoleteNames.contains(predecessor) | ||
| else { | ||
| return [] | ||
| } | ||
| return [predecessor] | ||
| } | ||
|
|
||
| static func takeDeletes( | ||
| forSavedRecordNames savedNames: [String], | ||
| pending: inout [String: Set<String>]) -> Set<String> | ||
| { | ||
| var toDrop: Set<String> = [] | ||
| for name in savedNames { | ||
| if let obsolete = pending.removeValue(forKey: name) { | ||
| toDrop.formUnion(obsolete) | ||
| } | ||
| } | ||
| return toDrop | ||
| } | ||
|
|
||
| static func retainingObsoletePredecessors( | ||
| in pending: inout [String: Set<String>], | ||
| obsoleteNames: Set<String>) | ||
| { | ||
| pending = pending.compactMapValues { predecessors in | ||
| let live = predecessors.intersection(obsoleteNames) | ||
| return live.isEmpty ? nil : live | ||
| } | ||
| } | ||
|
|
||
| static func assigningPredecessors( | ||
| _ predecessors: Set<String>, | ||
| to replacement: String, | ||
| pending: inout [String: Set<String>]) | ||
| { | ||
| if predecessors.isEmpty { | ||
| pending.removeValue(forKey: replacement) | ||
| } else { | ||
| pending[replacement] = predecessors | ||
| } | ||
| } | ||
|
|
||
| static func retryableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKRecord.ID] { | ||
| failures.compactMap { recordID, error in | ||
| self.retryDelay(for: error) == nil ? nil : recordID | ||
| } | ||
| } | ||
|
|
||
| static func reportableFailedDeletes(_ failures: [CKRecord.ID: CKError]) -> [CKError] { | ||
| failures.values.filter { error in | ||
| error.code != .unknownItem && self.retryDelay(for: error) == nil | ||
| } | ||
| } | ||
|
|
||
| /// Delayed retries only for recoverable CloudKit failures. Terminal per-record errors such as | ||
| /// `permissionFailure`, `notAuthenticated`, and `invalidArguments` are reported once. | ||
| static func retryDelay(for error: CKError) -> TimeInterval? { | ||
| switch error.code { | ||
| case .unknownItem, .permissionFailure, .notAuthenticated, .invalidArguments: | ||
| return nil | ||
| case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited, .zoneBusy, .quotaExceeded: | ||
| return max(error.retryAfterSeconds ?? 1, 1) | ||
| default: | ||
| guard let retryAfter = error.retryAfterSeconds else { return nil } | ||
|
Comment on lines
+286
to
+287
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When CloudKit reports Useful? React with 👍 / 👎. |
||
| return max(retryAfter, 1) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| static func finishedFailedDeleteNames(_ failures: [CKRecord.ID: CKError]) -> Set<String> { | ||
| Set(failures.compactMap { recordID, error in | ||
| self.retryDelay(for: error) == nil ? recordID.recordName : nil | ||
| }) | ||
| } | ||
|
|
||
| static func abandonedReplacementNames( | ||
| failures: [String: CKError], | ||
| pendingReplacements: Set<String>) -> Set<String> | ||
| { | ||
| Set(failures.compactMap { name, error in | ||
| pendingReplacements.contains(name) && self.retryDelay(for: error) == nil ? name : nil | ||
| }) | ||
| } | ||
|
|
||
| static func shouldResumeDelayedRetry( | ||
| originatingEngine: ObjectIdentifier?, | ||
| currentEngine: ObjectIdentifier?) -> Bool | ||
| { | ||
| guard let originatingEngine, let currentEngine else { return false } | ||
| return originatingEngine == currentEngine | ||
| } | ||
| } | ||
|
|
||
| enum CloudSyncEntitlementGate { | ||
| static let entitlement = "com.apple.developer.icloud-services" | ||
|
|
||
|
|
@@ -184,6 +310,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| private var lastSnapshotPushAt: Date? | ||
| private var pendingSnapshots: [AccountSnapshotSyncPayload] = [] | ||
| private var lastSnapshotHashes: [String: String] = [:] | ||
| private var pendingPredecessorDeletes: [String: Set<String>] = [:] | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| private var lastKnownProviderConfigs: [ProviderInstanceID: ProviderConfig] = [:] | ||
| private var lastKnownPreferences: SyncedPreferences? | ||
| private var lastKnownIncludeSecrets: Bool? | ||
|
|
@@ -248,6 +375,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| try await self.queueCurrentConfigurationAndPreferences() | ||
| guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } | ||
| try await self.queueDeviceRecord() | ||
| self.requeuePendingSnapshotDeletes() | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| self.startPeriodicFetchTimer() | ||
| self.scheduleFetchChanges(scopedToSyncZone: !initialized) | ||
| } catch { | ||
|
|
@@ -524,35 +652,6 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| self.persistEnvelope() | ||
| } | ||
|
|
||
| private func pushPendingSnapshots() async { | ||
| guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } | ||
| guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } | ||
| do { | ||
| for payload in self.pendingSnapshots { | ||
| let hash = try CanonicalSyncJSON.hash(payload) | ||
| guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } | ||
| let recordID = self.recordID(named: payload.recordName) | ||
| let record = self.record(type: .accountSnapshot, id: recordID) | ||
| record["schemaVersion"] = payload.schemaVersion as CKRecordValue | ||
| record["provider"] = payload.provider.rawValue as CKRecordValue | ||
| record["deviceID"] = payload.deviceID as CKRecordValue | ||
| record["accountKey"] = payload.accountKey as CKRecordValue | ||
| record["fetchedAt"] = payload.fetchedAt as CKRecordValue | ||
| record.encryptedValues["displayLabel"] = payload.displayLabel as CKRecordValue | ||
| record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue | ||
| self.desiredRecords[recordID] = record | ||
| self.lastSnapshotHashes[payload.recordName] = hash | ||
| self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload | ||
| engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) | ||
| } | ||
| self.pendingSnapshots = [] | ||
| self.lastSnapshotPushAt = Date() | ||
| self.persistEnvelope() | ||
| } catch { | ||
| await self.record(error: error) | ||
| } | ||
| } | ||
|
|
||
| // MARK: CKSyncEngineDelegate | ||
|
|
||
| nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { | ||
|
|
@@ -606,6 +705,12 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| for failure in changes.failedRecordSaves { | ||
| await self.handleSaveFailure(failure, syncEngine: syncEngine) | ||
| } | ||
| await self.handleSentRecordDeletes( | ||
| deletedIDs: changes.deletedRecordIDs, | ||
| failures: changes.failedRecordDeletes) | ||
| await self.finishConfirmedSnapshotMigrations( | ||
| savedRecordNames: changes.savedRecords.map(\.recordID.recordName), | ||
| syncEngine: syncEngine) | ||
| self.persistEnvelope() | ||
| if !changes.savedRecords.isEmpty { | ||
| await MainActor.run { self.state.status.lastSuccessfulPushAt = Date() } | ||
|
|
@@ -805,6 +910,7 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| self.recreateZoneAndRequeue(failure.record, syncEngine: syncEngine) | ||
| } else { | ||
| await self.record(error: failure.error) | ||
| self.abandonTerminalReplacementSave(failure) | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -1024,3 +1130,158 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| return String(bytes: bytes, encoding: .utf8) ?? "unknown" | ||
| } | ||
| } | ||
|
|
||
| extension CloudSyncEngine { | ||
| private func finishConfirmedSnapshotMigrations( | ||
| savedRecordNames: [String], | ||
| syncEngine: CKSyncEngine) async | ||
| { | ||
| let toDrop = CloudSyncSnapshotMigration.takeDeletes( | ||
| forSavedRecordNames: savedRecordNames, | ||
| pending: &self.pendingPredecessorDeletes) | ||
| for name in savedRecordNames { | ||
| guard self.lastSnapshotHashes[name] == nil, | ||
| let payload = self.persistenceEnvelope.fleetSnapshots[name], | ||
| let hash = try? CanonicalSyncJSON.hash(payload) | ||
| else { continue } | ||
| self.lastSnapshotHashes[name] = hash | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } | ||
| guard !toDrop.isEmpty else { return } | ||
| let recordIDs = CloudSyncSnapshotMigration.drop( | ||
| toDrop, | ||
| hashes: &self.lastSnapshotHashes, | ||
| envelope: &self.persistenceEnvelope, | ||
| desiredRecords: &self.desiredRecords, | ||
| zoneID: Self.zoneID) | ||
| for recordID in recordIDs { | ||
| syncEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) | ||
| } | ||
| self.rememberPendingSnapshotDeletes(toDrop) | ||
| await MainActor.run { | ||
| toDrop.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } | ||
| } | ||
| } | ||
|
|
||
| private func handleSentRecordDeletes(deletedIDs: [CKRecord.ID], failures: [CKRecord.ID: CKError]) async { | ||
| var finished = Set(deletedIDs.map(\.recordName)) | ||
| finished.formUnion(CloudSyncSnapshotMigration.finishedFailedDeleteNames(failures)) | ||
| self.forgetPendingSnapshotDeletes(finished) | ||
| for error in CloudSyncSnapshotMigration.reportableFailedDeletes(failures) { | ||
| await self.record(error: error) | ||
| } | ||
| for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures) { | ||
| self.rememberPendingSnapshotDeletes([recordID.recordName]) | ||
| let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 | ||
| self.scheduleDeleteRetry(recordID: recordID, after: delay) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| private func rememberPendingSnapshotDeletes(_ names: Set<String>) { | ||
| guard !names.isEmpty else { return } | ||
| self.persistenceEnvelope.pendingSnapshotDeletes.formUnion(names) | ||
| self.persistEnvelope() | ||
| } | ||
|
|
||
| private func forgetPendingSnapshotDeletes(_ names: Set<String>) { | ||
| let remaining = self.persistenceEnvelope.pendingSnapshotDeletes.subtracting(names) | ||
| guard remaining != self.persistenceEnvelope.pendingSnapshotDeletes else { return } | ||
| self.persistenceEnvelope.pendingSnapshotDeletes = remaining | ||
| self.persistEnvelope() | ||
| } | ||
|
|
||
| private func requeuePendingSnapshotDeletes() { | ||
| guard let engine = self.engine else { return } | ||
| for name in self.persistenceEnvelope.pendingSnapshotDeletes { | ||
| engine.state.add(pendingRecordZoneChanges: [.deleteRecord(self.recordID(named: name))]) | ||
| } | ||
| } | ||
|
|
||
| private func abandonTerminalReplacementSave( | ||
| _ failure: CKSyncEngine.Event.SentRecordZoneChanges.FailedRecordSave) | ||
| { | ||
| let name = failure.record.recordID.recordName | ||
| let abandoned = CloudSyncSnapshotMigration.abandonedReplacementNames( | ||
| failures: [name: failure.error], | ||
| pendingReplacements: Set(self.pendingPredecessorDeletes.keys)) | ||
| guard abandoned.contains(name), | ||
| self.pendingPredecessorDeletes.removeValue(forKey: name) != nil | ||
| else { return } | ||
| if self.lastSnapshotHashes[name] == nil, | ||
| let payload = self.persistenceEnvelope.fleetSnapshots[name], | ||
| let hash = try? CanonicalSyncJSON.hash(payload) | ||
| { | ||
| self.lastSnapshotHashes[name] = hash | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { | ||
| Task { [weak self] in | ||
| let originatingEngine = await self?.engine.map { ObjectIdentifier($0) } | ||
| do { | ||
| if delay > 0 { | ||
| try await Task.sleep(for: .seconds(delay)) | ||
| } | ||
| await Task.yield() | ||
| guard let self, await self.enabled else { return } | ||
| guard let engine = await self.engine, | ||
| CloudSyncSnapshotMigration.shouldResumeDelayedRetry( | ||
| originatingEngine: originatingEngine, | ||
| currentEngine: ObjectIdentifier(engine)) | ||
| else { return } | ||
| engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| try await engine.sendChanges(.init(scope: .recordIDs([recordID]))) | ||
| } catch is CancellationError { | ||
| return | ||
| } catch { | ||
| await self?.record(error: error) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func pushPendingSnapshots() async { | ||
| guard let engine = self.engine, !self.pendingSnapshots.isEmpty else { return } | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| guard await MainActor.run(body: { !self.state.status.needsAppUpdate }) else { return } | ||
| do { | ||
| let obsoleteNames = CloudSyncSnapshotMigration.obsoleteRecordNames( | ||
| liveSnapshots: self.pendingSnapshots, | ||
| hashes: self.lastSnapshotHashes, | ||
| envelope: self.persistenceEnvelope) | ||
| CloudSyncSnapshotMigration.retainingObsoletePredecessors( | ||
| in: &self.pendingPredecessorDeletes, | ||
| obsoleteNames: obsoleteNames) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| for payload in self.pendingSnapshots { | ||
| let hash = try CanonicalSyncJSON.hash(payload) | ||
| guard self.lastSnapshotHashes[payload.recordName] != hash else { continue } | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| let recordID = self.recordID(named: payload.recordName) | ||
| let record = self.record(type: .accountSnapshot, id: recordID) | ||
| record["schemaVersion"] = payload.schemaVersion as CKRecordValue | ||
| record["provider"] = payload.provider.rawValue as CKRecordValue | ||
| record["deviceID"] = payload.deviceID as CKRecordValue | ||
| record["accountKey"] = payload.accountKey as CKRecordValue | ||
| record["fetchedAt"] = payload.fetchedAt as CKRecordValue | ||
| record.encryptedValues["displayLabel"] = payload.displayLabel as CKRecordValue | ||
| record.encryptedValues["usagePayload"] = try CanonicalSyncJSON.string(payload.usage) as CKRecordValue | ||
| self.desiredRecords[recordID] = record | ||
| self.persistenceEnvelope.fleetSnapshots[payload.recordName] = payload | ||
| let predecessors = CloudSyncSnapshotMigration.predecessorNames( | ||
| for: payload, | ||
| obsoleteNames: obsoleteNames) | ||
| if predecessors.isEmpty { | ||
| self.lastSnapshotHashes[payload.recordName] = hash | ||
| } | ||
| // Replace, don't union: a later live email-keyed snapshot must not stay queued | ||
| // for delete after the slot-keyed save is confirmed. | ||
| CloudSyncSnapshotMigration.assigningPredecessors( | ||
| predecessors, | ||
| to: payload.recordName, | ||
| pending: &self.pendingPredecessorDeletes) | ||
| engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) | ||
| } | ||
| self.pendingSnapshots = [] | ||
| self.lastSnapshotPushAt = Date() | ||
| self.persistEnvelope() | ||
| } catch { | ||
| await self.record(error: error) | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.