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 2 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,87 @@ 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 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
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| enum CloudSyncEntitlementGate { | ||
| static let entitlement = "com.apple.developer.icloud-services" | ||
|
|
||
|
|
@@ -184,6 +265,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? | ||
|
|
@@ -524,35 +606,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 +659,10 @@ actor CloudSyncEngine: CKSyncEngineDelegate { | |
| for failure in changes.failedRecordSaves { | ||
| await self.handleSaveFailure(failure, syncEngine: syncEngine) | ||
| } | ||
| await self.handleFailedDeletes(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() } | ||
|
|
@@ -1024,3 +1081,102 @@ 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)]) | ||
| } | ||
| await MainActor.run { | ||
| toDrop.forEach { self.state.fleetSnapshots.removeValue(forKey: $0) } | ||
| } | ||
| } | ||
|
|
||
| private func handleFailedDeletes(_ failures: [CKRecord.ID: CKError]) async { | ||
| for error in CloudSyncSnapshotMigration.reportableFailedDeletes(failures) { | ||
| await self.record(error: error) | ||
| } | ||
| for recordID in CloudSyncSnapshotMigration.retryableFailedDeletes(failures) { | ||
| let delay = failures[recordID].flatMap(CloudSyncSnapshotMigration.retryDelay(for:)) ?? 1 | ||
| self.scheduleDeleteRetry(recordID: recordID, after: delay) | ||
| } | ||
| } | ||
|
|
||
| private func scheduleDeleteRetry(recordID: CKRecord.ID, after delay: TimeInterval) { | ||
| Task { [weak self] in | ||
| do { | ||
| if delay > 0 { | ||
| try await Task.sleep(for: .seconds(delay)) | ||
| } | ||
| await Task.yield() | ||
| guard let self, let engine = await self.engine, await self.enabled 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]))) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } 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) | ||
| 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 | ||
| } else { | ||
| // Keep the hash unset until CloudKit confirms the replacement save so a | ||
| // failed send can retry, and so the predecessor is not deleted first. | ||
| self.pendingPredecessorDeletes[payload.recordName, default: []].formUnion(predecessors) | ||
|
sf-jin-ku marked this conversation as resolved.
Resolved
sf-jin-ku marked this conversation as resolved.
Resolved
|
||
| } | ||
| 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.