├── Package.swift ├── CloudKitQueues.podspec ├── LICENSE ├── README.md └── Source └── CloudKitQueue.swift /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CloudKitQueues", 8 | platforms: [.iOS(.v14), .macOS(.v11)], 9 | products: [ 10 | .library(name: "CloudKitQueues", targets: ["CloudKitQueues"]) 11 | ], 12 | targets: [ 13 | .target(name: "CloudKitQueues", path: "Source") 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /CloudKitQueues.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "CloudKitQueues" 3 | s.version = "0.0.3" 4 | s.summary = "A queue manager for simplifying individual and batch operations on CloudKit records" 5 | s.homepage = "https://github.com/sobri909/CloudKitQueues" 6 | s.author = { "Matt Greenfield" => "matt@bigpaua.com" } 7 | s.license = { :type => "MIT", :file => "LICENSE" } 8 | s.source = { :git => 'https://github.com/sobri909/CloudKitQueues.git', :tag => '0.0.3' } 9 | s.source_files = 'Source/*.swift' 10 | s.swift_version = '4.2' 11 | s.ios.deployment_target = '11.0' 12 | s.frameworks = 'CloudKit' 13 | end 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matt Greenfield 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudKitQueues 2 | 3 | A queue manager for simplifying both individual and batch operations on CloudKit records. 4 | 5 | ## Seamless Individual and Batch Operations 6 | 7 | CloudKitQueues provides methods for performing fetch, save, and delete actions on individual CKRecords 8 | and CKRecordIDs. The actions are then intelligently batched together, to get the fastest results in the 9 | least amount of CloudKit requests. 10 | 11 | This makes it possible to get the best possible performance out of CloudKit, with the minimum 12 | number of requests within CloudKit's request limits. 13 | 14 | ## No Manual Queue Management Required 15 | 16 | With CloudKitQueues you no longer need to manage batch CloudKit operations in your loops, nor 17 | manage failures or retries on batch operations. Simply call `fetch()`, `save()`, `delete()` on 18 | individual CKRecords and CKRecordIDs, and CloudKitQueues will ensure that they are batched 19 | together in the most efficient manner. 20 | 21 | The same methods are suitable both for single standalone operations and large batch operations. 22 | CloudKitQueues will ensure that the they are performed optimally in all cases. 23 | 24 | ## Individual Completion Closures 25 | 26 | Every fetch, save, and delete request takes an optional completion closure, which will be executed 27 | once the record's database action has completed. This allows the batching of hundreds of concurrent 28 | database operations together, while being able to easily perform completion actions on each record 29 | individually. 30 | 31 | ```swift 32 | queue.fetch(ckRecordId) { ckRecord, error in 33 | if let ckRecord = ckRecord { 34 | print("ckRecord: " + ckRecord) 35 | } 36 | } 37 | ``` 38 | 39 | ## Automatic Rate Limit Management 40 | 41 | CloudKitQueues observes and respects CloudKit's rate limit timeouts, and automatically retries once the 42 | timeouts permit. 43 | 44 | ## Fast and Slow Queues 45 | 46 | CloudKitQueues manages two sets of queues: 47 | 48 | - **Fast Queue:** For fast operations, eg actions for which the user is waiting to see the result 49 | in the UI. 50 | - **Slow Queue:** For slow operations, eg backups, restores, and deletes that the user is 51 | not expecting to see the immediate results of. 52 | 53 | When fetching a record you can choose to either `queue.fetch(ckRecordId)` or 54 | `queue.slowFetch(ckRecordId)`. 55 | 56 | When saving changes to a record you can choose to either `queue.save(ckRecord)` or 57 | `queue.slowSave(ckRecord)`. 58 | 59 | When deleting a record you can choose to either `queue.delete(ckRecordId)` or 60 | `queue.slowDelete(ckRecordId)`. 61 | 62 | CloudKitQueues manages these queues internally so that "fast" actions are batched together and 63 | performed as quickly as possible, and "slow" actions are batched together and performed when 64 | CloudKit determines that the energy and network conditions best suit. 65 | 66 | ## Setup 67 | 68 | ```ruby 69 | pod 'CloudKitQueues' 70 | ``` 71 | 72 | Or just drop [CloudKitQueue.swift](https://github.com/sobri909/CloudKitQueues/blob/master/Source/CloudKitQueue.swift) into your project. 73 | 74 | ## Examples 75 | 76 | ```swift 77 | let publicQueue = CloudKitQueue(for: CKContainer.default().publicCloudDatabase) 78 | let privateQueue = CloudKitQueue(for: CKContainer.default().privateCloudDatabase) 79 | ``` 80 | 81 | #### Fast Queue Actions 82 | 83 | Note that all `save()`, `fetch()`, and `delete()` methods accept optional completion closures. 84 | 85 | ```swift 86 | // save the CKRecords for all cars 87 | for car in cars { 88 | publicQueue.save(car.ckRecord) 89 | } 90 | ``` 91 | 92 | ```swift 93 | // fetch the CKRecords for all cars, and assign the fetched records 94 | // to their car objects on fetch completion 95 | for car in cars { 96 | publicQueue.fetch(car.ckRecordId) { ckRecord, error in 97 | car.ckRecord = ckRecord 98 | } 99 | } 100 | ``` 101 | 102 | ```swift 103 | // delete the remote CKRecords for all cars 104 | for car in cars { 105 | publicQueue.delete(car.ckRecordId) 106 | } 107 | ``` 108 | 109 | #### Slow Queue Actions 110 | 111 | ```swift 112 | // slow save the CKRecords for all cars 113 | for car in cars { 114 | publicQueue.slowSave(car.ckRecord) 115 | } 116 | ``` 117 | 118 | ```swift 119 | // slow fetch the CKRecords for all cars, and assign the fetched records 120 | // to their car objects on fetch completion 121 | for car in cars { 122 | publicQueue.slowFetch(car.ckRecordId) { ckRecord, error in 123 | car.ckRecord = ckRecord 124 | } 125 | } 126 | ``` 127 | 128 | ```swift 129 | // slow delete the remote CKRecords for all cars 130 | for car in cars { 131 | publicQueue.slowDelete(car.ckRecordId) 132 | } 133 | ``` 134 | -------------------------------------------------------------------------------- /Source/CloudKitQueue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Matt Greenfield on 29/07/16. 3 | // Copyright © 2016 Big Paua. All rights reserved. 4 | // 5 | 6 | import os.log 7 | import CloudKit 8 | 9 | public extension NSNotification.Name { 10 | static let updatedCloudKitQueueProgress = Notification.Name("updatedCloudKitQueueProgress") 11 | } 12 | 13 | public typealias CKFetchCompletion = (_ ckRecord: CKRecord?, _ error: Error?) -> Void 14 | public typealias CKSaveCompletion = (_ ckRecord: CKRecord?, _ error: Error?) -> Void 15 | public typealias CKDeleteCompletion = (_ error: Error?) -> Void 16 | 17 | public class CloudKitQueue { 18 | 19 | public let database: CKDatabase 20 | 21 | // MARK: - Init 22 | 23 | public init(for database: CKDatabase) { self.database = database } 24 | 25 | deinit { pthread_mutex_destroy(&mutex) } 26 | 27 | // MARK: - Settings 28 | 29 | public var isPaused = false 30 | public var batchSize = 200 31 | 32 | // MARK: - CKOperation properties 33 | 34 | private var fetchOperation: CKFetchRecordsOperation? 35 | private var saveOperation: CKModifyRecordsOperation? 36 | private var deleteOperation: CKModifyRecordsOperation? 37 | 38 | private var slowFetchOperation: CKFetchRecordsOperation? 39 | private var slowSaveOperation: CKModifyRecordsOperation? 40 | private var slowDeleteOperation: CKModifyRecordsOperation? 41 | 42 | // MARK: - Queue properties 43 | 44 | private var recordsToFetch: [CKRecord.ID: [CKFetchCompletion]] = [:] 45 | private var recordsToSave: [CKRecord: [CKSaveCompletion]] = [:] 46 | private var recordsToDelete: [CKRecord.ID: [CKDeleteCompletion]] = [:] 47 | 48 | private var recordsToSlowFetch: [CKRecord.ID: [CKFetchCompletion]] = [:] 49 | private var recordsToSlowSave: [CKRecord: [CKSaveCompletion]] = [:] 50 | private var recordsToSlowDelete: [CKRecord.ID: [CKDeleteCompletion]] = [:] 51 | 52 | private var queuedFetches = 0 { didSet { needsProgressUpdate() } } 53 | private var queuedSaves = 0 { didSet { needsProgressUpdate() } } 54 | private var queuedDeletes = 0 { didSet { needsProgressUpdate() } } 55 | 56 | private var queuedSlowFetches = 0 { didSet { needsProgressUpdate() } } 57 | private var queuedSlowSaves = 0 { didSet { needsProgressUpdate() } } 58 | private var queuedSlowDeletes = 0 { didSet { needsProgressUpdate() } } 59 | 60 | public private(set) var quotaExceeded = false 61 | public private(set) var timeoutUntil: Date? 62 | 63 | private var progressUpdateTimer: Timer? 64 | 65 | private let queue = DispatchQueue(label: "CloudKitQueue") 66 | 67 | private var mutex: pthread_mutex_t = { 68 | var mutex = pthread_mutex_t() 69 | var attr = pthread_mutexattr_t() 70 | pthread_mutexattr_init(&attr) 71 | pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE) 72 | pthread_mutex_init(&mutex, &attr) 73 | return mutex 74 | }() 75 | 76 | // MARK: - Queue content checking 77 | 78 | public func isFetching(_ recordId: CKRecord.ID) -> Bool { 79 | return sync { self.recordsToFetch[recordId] != nil || self.recordsToSlowFetch[recordId] != nil } 80 | } 81 | 82 | public func isSaving(_ record: CKRecord) -> Bool { 83 | return sync { self.recordsToSave[record] != nil || self.recordsToSlowSave[record] != nil } 84 | } 85 | 86 | // MARK: - Fast queue 87 | 88 | public func fetch(_ recordId: CKRecord.ID, completion: @escaping CKFetchCompletion) { 89 | sync { 90 | if var existing = self.recordsToFetch[recordId] { 91 | existing.append(completion) 92 | self.recordsToFetch[recordId] = existing 93 | 94 | } else { 95 | self.recordsToFetch[recordId] = [completion] 96 | self.queuedFetches += 1 97 | } 98 | } 99 | runFetches() 100 | } 101 | 102 | public func save(_ record: CKRecord, completion: @escaping CKSaveCompletion) { 103 | sync { 104 | if var existing = recordsToSave[record] { 105 | existing.append(completion) 106 | self.recordsToSave[record] = existing 107 | 108 | } else { 109 | self.recordsToSave[record] = [completion] 110 | self.queuedSaves += 1 111 | } 112 | } 113 | runSaves() 114 | } 115 | 116 | public func delete(_ recordId: CKRecord.ID, completion: CKDeleteCompletion? = nil) { 117 | sync { 118 | if var existing = self.recordsToDelete[recordId] { 119 | if let completion = completion { 120 | existing.append(completion) 121 | self.recordsToDelete[recordId] = existing 122 | } 123 | 124 | } else { 125 | if let completion = completion { 126 | self.recordsToDelete[recordId] = [completion] 127 | } else { 128 | self.recordsToDelete[recordId] = [] 129 | } 130 | self.queuedDeletes += 1 131 | } 132 | } 133 | runDeletes() 134 | } 135 | 136 | // MARK: - Slow queue 137 | 138 | public func slowFetch(_ recordId: CKRecord.ID, completion: @escaping CKFetchCompletion) { 139 | sync { 140 | if var existing = self.recordsToSlowFetch[recordId] { 141 | existing.append(completion) 142 | self.recordsToSlowFetch[recordId] = existing 143 | 144 | } else { 145 | self.recordsToSlowFetch[recordId] = [completion] 146 | self.queuedSlowFetches += 1 147 | } 148 | } 149 | runSlowFetches() 150 | } 151 | 152 | public func slowSave(_ record: CKRecord, completion: @escaping CKSaveCompletion) { 153 | sync { 154 | if var existing = self.recordsToSlowSave[record] { 155 | existing.append(completion) 156 | self.recordsToSlowSave[record] = existing 157 | 158 | } else { 159 | self.recordsToSlowSave[record] = [completion] 160 | self.queuedSlowSaves += 1 161 | } 162 | } 163 | runSlowSaves() 164 | } 165 | 166 | public func slowDelete(_ recordId: CKRecord.ID, completion: CKDeleteCompletion? = nil) { 167 | sync { 168 | if var existing = self.recordsToSlowDelete[recordId] { 169 | if let completion = completion { 170 | existing.append(completion) 171 | self.recordsToSlowDelete[recordId] = existing 172 | } 173 | 174 | } else { 175 | if let completion = completion { 176 | self.recordsToSlowDelete[recordId] = [completion] 177 | } else { 178 | self.recordsToSlowDelete[recordId] = [] 179 | } 180 | self.queuedSlowDeletes += 1 181 | } 182 | } 183 | runSlowDeletes() 184 | } 185 | 186 | // MARK: - Thread safe operation adding 187 | 188 | public func add(_ operation: CKDatabaseOperation) { 189 | queue.async { self.database.add(operation) } 190 | } 191 | 192 | // MARK: - Queue counts 193 | 194 | public var queueTotal: Int { return sync { queuedFetches + queuedSaves + queuedDeletes } } 195 | public var slowQueueTotal: Int { return sync { queuedSlowFetches + queuedSlowSaves + queuedSlowDeletes } } 196 | 197 | public var queueRemaining: Int { return sync { recordsToFetch.count + recordsToSave.count + recordsToDelete.count } } 198 | public var slowQueueRemaining: Int { return sync { recordsToSlowFetch.count + recordsToSlowSave.count + recordsToSlowDelete.count } } 199 | 200 | // MARK: - Queue progress 201 | 202 | public var progress: Double { 203 | return sync { 204 | guard queueTotal > 0 else { return 1 } 205 | return (1.0 - Double(queueRemaining) / Double(queueTotal)).clamped(min: 0, max: 1) 206 | } 207 | } 208 | 209 | public var slowProgress: Double { 210 | return sync { 211 | guard slowQueueTotal > 0 else { return 1 } 212 | return (1.0 - Double(slowQueueRemaining) / Double(slowQueueTotal)).clamped(min: 0, max: 1) 213 | } 214 | } 215 | 216 | private func needsProgressUpdate() { 217 | onMain { 218 | guard self.progressUpdateTimer == nil else { return } 219 | 220 | self.progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in 221 | self.sendProgressUpdate() 222 | } 223 | } 224 | } 225 | 226 | private func sendProgressUpdate() { 227 | progressUpdateTimer = nil 228 | let note = Notification(name: .updatedCloudKitQueueProgress, object: self, userInfo: nil) 229 | NotificationCenter.default.post(note) 230 | } 231 | 232 | // MARK: - Running the fast queue 233 | 234 | private func runFetches() { 235 | queue.async { 236 | if self.isPaused { return } 237 | 238 | guard self.fetchOperation == nil else { return } 239 | 240 | guard !self.timedout else { 241 | self.afterTimeout { self.runFetches() } 242 | return 243 | } 244 | 245 | let recordIds = self.sync { Array(self.recordsToFetch.keys) } 246 | 247 | let batch = recordIds.count <= self.batchSize ? recordIds : Array(recordIds[0 ..< self.batchSize]) 248 | 249 | let operation = CKFetchRecordsOperation(recordIDs: batch) 250 | operation.configuration.qualityOfService = .userInitiated 251 | operation.configuration.allowsCellularAccess = true 252 | self.fetchOperation = operation 253 | 254 | operation.perRecordCompletionBlock = { ckRecord, ckRecordId, error in 255 | guard let ckRecordId = ckRecordId else { return } 256 | 257 | self.queue.async { 258 | guard let completions = self.sync(execute: { self.recordsToFetch[ckRecordId] }) else { return } 259 | 260 | self.sync { self.recordsToFetch.removeValue(forKey: ckRecordId) } 261 | 262 | for completion in completions { 263 | completion(ckRecord, error) 264 | } 265 | 266 | self.needsProgressUpdate() 267 | } 268 | } 269 | 270 | operation.fetchRecordsCompletionBlock = { ckRecords, error in 271 | if let error = error as NSError? { 272 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 273 | os_log("fetchRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 274 | } 275 | } 276 | 277 | self.queue.async { 278 | self.fetchOperation = nil 279 | 280 | if self.recordsToFetch.isEmpty { 281 | self.queuedFetches = 0 282 | 283 | } else { 284 | self.runFetches() 285 | } 286 | } 287 | } 288 | 289 | self.database.add(operation) 290 | } 291 | } 292 | 293 | private func runSaves() { 294 | queue.async { 295 | if self.isPaused { return } 296 | 297 | guard self.saveOperation == nil else { return } 298 | 299 | guard !self.timedout else { 300 | self.afterTimeout { self.runSaves() } 301 | return 302 | } 303 | 304 | let records = self.sync { Array(self.recordsToSave.keys) } 305 | 306 | let batch = records.count <= self.batchSize ? records : Array(records[0 ..< self.batchSize]) 307 | 308 | let operation = CKModifyRecordsOperation(recordsToSave: batch, recordIDsToDelete: nil) 309 | operation.configuration.qualityOfService = .userInitiated 310 | operation.configuration.allowsCellularAccess = true 311 | operation.isAtomic = false 312 | self.saveOperation = operation 313 | 314 | operation.perRecordCompletionBlock = { ckRecord, error in 315 | self.queue.async { 316 | if let error = error as NSError?, error.code == CKError.Code.quotaExceeded.rawValue { 317 | self.quotaExceeded = true 318 | } 319 | 320 | guard let completions = self.sync(execute: { self.recordsToSave[ckRecord] }) else { return } 321 | 322 | self.sync { self.recordsToSave.removeValue(forKey: ckRecord) } 323 | 324 | for completion in completions { 325 | completion(ckRecord, error) 326 | } 327 | 328 | self.needsProgressUpdate() 329 | } 330 | } 331 | 332 | operation.modifyRecordsCompletionBlock = { savedRecords, deletedRecordIds, error in 333 | if let error = error as NSError? { 334 | if error.code == CKError.Code.quotaExceeded.rawValue { self.quotaExceeded = true } 335 | 336 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 337 | os_log("modifyRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 338 | 339 | self.sync { 340 | for ckRecord in batch { 341 | guard let completions = self.recordsToSave[ckRecord] else { continue } 342 | 343 | self.recordsToSave.removeValue(forKey: ckRecord) 344 | 345 | for completion in completions { 346 | completion(ckRecord, error) 347 | } 348 | } 349 | } 350 | } 351 | } 352 | 353 | self.queue.async { 354 | self.saveOperation = nil 355 | 356 | if self.recordsToSave.isEmpty { 357 | self.queuedSaves = 0 358 | 359 | } else { 360 | self.runSaves() 361 | } 362 | } 363 | } 364 | 365 | self.database.add(operation) 366 | } 367 | } 368 | 369 | private func runDeletes() { 370 | queue.async { 371 | if self.isPaused { return } 372 | 373 | guard self.deleteOperation == nil else { return } 374 | 375 | guard !self.timedout else { 376 | self.afterTimeout { self.runDeletes() } 377 | return 378 | } 379 | 380 | let recordIds = self.sync { Array(self.recordsToDelete.keys) } 381 | let batch = recordIds.count <= self.batchSize ? recordIds : Array(recordIds[0 ..< self.batchSize]) 382 | 383 | let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: batch) 384 | operation.configuration.qualityOfService = .userInitiated 385 | operation.configuration.allowsCellularAccess = true 386 | operation.isAtomic = false 387 | self.deleteOperation = operation 388 | 389 | operation.modifyRecordsCompletionBlock = { savedRecords, deletedRecordIds, error in 390 | if let error = error as NSError? { 391 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 392 | os_log("modifyRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 393 | } 394 | } 395 | 396 | self.queue.async { 397 | self.deleteOperation = nil 398 | 399 | if let deletedRecordIds = deletedRecordIds { 400 | for recordId in deletedRecordIds { 401 | guard let completions = self.sync(execute: { self.recordsToDelete[recordId] }) else { return } 402 | 403 | self.sync { self.recordsToDelete.removeValue(forKey: recordId) } 404 | 405 | for completion in completions { 406 | completion(error) 407 | } 408 | } 409 | } 410 | 411 | if self.recordsToDelete.isEmpty { 412 | self.queuedDeletes = 0 413 | 414 | } else { 415 | self.runDeletes() 416 | } 417 | } 418 | } 419 | 420 | self.database.add(operation) 421 | } 422 | } 423 | 424 | // MARK: - Running the slow queue 425 | 426 | private func runSlowFetches() { 427 | queue.async { 428 | if self.isPaused { return } 429 | 430 | guard self.slowFetchOperation == nil else { return } 431 | 432 | guard !self.timedout else { 433 | self.afterTimeout { self.runSlowFetches() } 434 | return 435 | } 436 | 437 | let recordIds = self.sync { Array(self.recordsToSlowFetch.keys) } 438 | let batch = recordIds.count <= self.batchSize ? recordIds : Array(recordIds[0 ..< self.batchSize]) 439 | 440 | let operation = CKFetchRecordsOperation(recordIDs: batch) 441 | operation.configuration.qualityOfService = .background 442 | operation.configuration.allowsCellularAccess = false 443 | self.slowFetchOperation = operation 444 | 445 | operation.perRecordCompletionBlock = { ckRecord, ckRecordId, error in 446 | guard let ckRecordId = ckRecordId else { return } 447 | 448 | self.queue.async { 449 | guard let completions = self.sync(execute: { self.recordsToSlowFetch[ckRecordId] }) else { return } 450 | 451 | self.sync { self.recordsToSlowFetch.removeValue(forKey: ckRecordId) } 452 | 453 | for completion in completions { 454 | completion(ckRecord, error) 455 | } 456 | 457 | self.needsProgressUpdate() 458 | } 459 | } 460 | 461 | operation.fetchRecordsCompletionBlock = { ckRecords, error in 462 | if let error = error as NSError? { 463 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 464 | os_log("fetchRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 465 | } 466 | } 467 | 468 | self.queue.async { 469 | self.slowFetchOperation = nil 470 | 471 | if self.recordsToSlowFetch.isEmpty { 472 | self.queuedSlowFetches = 0 473 | 474 | } else { 475 | self.runSlowFetches() 476 | } 477 | } 478 | } 479 | 480 | self.database.add(operation) 481 | } 482 | } 483 | 484 | private func runSlowSaves() { 485 | queue.async { 486 | if self.isPaused { return } 487 | 488 | guard self.slowSaveOperation == nil else { return } 489 | 490 | guard !self.timedout else { 491 | self.afterTimeout { self.runSlowSaves() } 492 | return 493 | } 494 | 495 | let records = self.sync { Array(self.recordsToSlowSave.keys) } 496 | 497 | let batch = records.count <= self.batchSize ? records : Array(records[0 ..< self.batchSize]) 498 | 499 | let operation = CKModifyRecordsOperation(recordsToSave: batch, recordIDsToDelete: nil) 500 | operation.configuration.qualityOfService = .background 501 | operation.configuration.allowsCellularAccess = false 502 | operation.isAtomic = false 503 | self.slowSaveOperation = operation 504 | 505 | operation.perRecordCompletionBlock = { ckRecord, error in 506 | self.queue.async { 507 | if let error = error as NSError?, error.code == CKError.Code.quotaExceeded.rawValue { 508 | self.quotaExceeded = true 509 | } 510 | 511 | guard let completions = self.sync(execute: { self.recordsToSlowSave[ckRecord] }) else { return } 512 | 513 | self.sync { self.recordsToSlowSave.removeValue(forKey: ckRecord) } 514 | 515 | for completion in completions { 516 | completion(ckRecord, error) 517 | } 518 | 519 | self.needsProgressUpdate() 520 | } 521 | } 522 | 523 | operation.modifyRecordsCompletionBlock = { savedRecords, deletedRecordIds, error in 524 | if let error = error as NSError? { 525 | if error.code == CKError.Code.quotaExceeded.rawValue { self.quotaExceeded = true } 526 | 527 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 528 | os_log("modifyRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 529 | 530 | self.sync { 531 | for ckRecord in batch { 532 | if let completions = self.recordsToSlowSave[ckRecord] { 533 | self.recordsToSlowSave.removeValue(forKey: ckRecord) 534 | 535 | for completion in completions { 536 | completion(ckRecord, error) 537 | } 538 | } 539 | } 540 | } 541 | } 542 | } 543 | 544 | self.queue.async { 545 | self.slowSaveOperation = nil 546 | 547 | if self.recordsToSlowSave.isEmpty { 548 | self.queuedSlowSaves = 0 549 | 550 | } else { 551 | self.runSlowSaves() 552 | } 553 | } 554 | } 555 | 556 | self.database.add(operation) 557 | } 558 | } 559 | 560 | private func runSlowDeletes() { 561 | queue.async { 562 | if self.isPaused { return } 563 | 564 | guard self.slowDeleteOperation == nil else { return } 565 | 566 | guard !self.timedout else { 567 | self.afterTimeout { self.runSlowDeletes() } 568 | return 569 | } 570 | 571 | let recordIds = self.sync { Array(self.recordsToSlowDelete.keys) } 572 | 573 | let batch = recordIds.count <= self.batchSize ? recordIds : Array(recordIds[0 ..< self.batchSize]) 574 | 575 | let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: batch) 576 | operation.configuration.qualityOfService = .background 577 | operation.configuration.allowsCellularAccess = false 578 | operation.isAtomic = false 579 | self.slowDeleteOperation = operation 580 | 581 | operation.modifyRecordsCompletionBlock = { savedRecords, deletedRecordIds, error in 582 | if let error = error as NSError? { 583 | if !self.rateLimited(error) && error.code != CKError.Code.partialFailure.rawValue { 584 | os_log("modifyRecordsCompletionBlock error: %@", type: .error, error.localizedDescription) 585 | } 586 | } 587 | 588 | self.queue.async { 589 | self.slowDeleteOperation = nil 590 | 591 | if let deletedRecordIds = deletedRecordIds { 592 | for recordId in deletedRecordIds { 593 | guard let completions = self.sync(execute: { self.recordsToSlowDelete[recordId] }) else { return } 594 | 595 | self.sync { self.recordsToSlowDelete.removeValue(forKey: recordId) } 596 | 597 | for completion in completions { 598 | completion(error) 599 | } 600 | } 601 | } 602 | 603 | if self.recordsToSlowDelete.isEmpty { 604 | self.queuedSlowDeletes = 0 605 | 606 | } else { 607 | self.runSlowDeletes() 608 | } 609 | } 610 | } 611 | 612 | self.database.add(operation) 613 | } 614 | } 615 | 616 | // MARK: - Rate limit management 617 | 618 | func rateLimited(_ error: NSError) -> Bool { 619 | let rateLimitErrors = [CKError.requestRateLimited.rawValue, CKError.zoneBusy.rawValue] 620 | 621 | guard rateLimitErrors.contains(error.code) else { return false } 622 | 623 | sync { 624 | if let timeout = error.userInfo[CKErrorRetryAfterKey] as? TimeInterval { 625 | os_log("CloudKit timeout: %.1f", type: .debug, timeout) 626 | self.timeoutUntil = Date(timeIntervalSinceNow: timeout) 627 | 628 | } else { 629 | os_log("CloudKit timeout", type: .debug) 630 | self.timeoutUntil = Date(timeIntervalSinceNow: 60) 631 | } 632 | } 633 | 634 | return true 635 | } 636 | 637 | public var timedout: Bool { 638 | guard let untilTimeoutOver = timeoutUntil?.timeIntervalSinceNow else { return false } 639 | return untilTimeoutOver > 0 640 | } 641 | 642 | func afterTimeout(_ closure: @escaping () -> ()) { 643 | let timeout = sync { self.timeoutUntil } 644 | 645 | if let timeout = timeout { 646 | delay(timeout.timeIntervalSinceNow) { 647 | closure() 648 | } 649 | } else { 650 | closure() 651 | } 652 | } 653 | 654 | // MARK: - Mutex, etc 655 | 656 | @discardableResult private func sync(execute work: () throws -> R) rethrows -> R { 657 | pthread_mutex_lock(&mutex) 658 | defer { pthread_mutex_unlock(&mutex) } 659 | return try work() 660 | } 661 | 662 | } 663 | 664 | // MARK: - Helpers 665 | 666 | func onMain(_ closure: @escaping () -> ()) { 667 | if Thread.isMainThread { closure() } 668 | else { DispatchQueue.main.async(execute: closure) } 669 | } 670 | 671 | internal func delay(_ delay: Double, closure: @escaping () -> ()) { 672 | DispatchQueue.main.asyncAfter( 673 | deadline: DispatchTime.now() + Double(Int64(delay * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), 674 | execute: closure) 675 | } 676 | 677 | internal extension Comparable { 678 | 679 | mutating func clamp(min: Self, max: Self) { 680 | if self < min { self = min } 681 | if self > max { self = max } 682 | } 683 | 684 | func clamped(min: Self, max: Self) -> Self { 685 | var result = self 686 | if result < min { result = min } 687 | if result > max { result = max } 688 | return result 689 | } 690 | 691 | } 692 | --------------------------------------------------------------------------------