Skip to content

Commit

Permalink
Merge pull request #33 from deeje/feature/5.1
Browse files Browse the repository at this point in the history
5.1 - improved sync and upload
  • Loading branch information
deeje authored Jun 1, 2022
2 parents 8d609c4 + fb9b5e4 commit 17a0b8d
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 242 deletions.
2 changes: 1 addition & 1 deletion CloudCore.podspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Pod::Spec.new do |s|
s.name = "CloudCore"
s.summary = "Framework that enables synchronization between CloudKit and Core Data."
s.version = "5.0.2"
s.version = "5.1.0"
s.homepage = "https://github.com/deeje/CloudCore"
s.license = 'MIT'
s.author = { "deeje" => "[email protected]", "Vasily Ulianov" => "[email protected]" }
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# CloudCore

![Platform](https://img.shields.io/cocoapods/p/CloudCore.svg?style=flat)
![Status](https://img.shields.io/badge/status-beta-orange.svg)
![Status](https://img.shields.io/badge/status-production-green.svg)
![Swift](https://img.shields.io/badge/swift-5.0-orange.svg)

**CloudCore** is an advanced sync engine for CloudKit and Core Data.
Expand Down Expand Up @@ -118,7 +118,7 @@ func application(_ application: UIApplication, didReceiveRemoteNotification user
}
```

6. If you want to enable offline support, **enable NSPersistentHistoryTracking** when you initialize your Core Data stack
6. **Enable NSPersistentHistoryTracking** when you initialize your Core Data stack

```swift
lazy var persistentContainer: NSPersistentContainer = {
Expand All @@ -136,7 +136,7 @@ lazy var persistentContainer: NSPersistentContainer = {
}()
```

7. To identify changes from your app that should be pushed, **save** from a background ManagedObjectContext named `CloudCorePushContext`, or use the convenience function performBackgroundPushTask
7. To identify changes from your app that should be pushed, **save** from the convenience function performBackgroundPushTask

```swift
persistentContainer.performBackgroundPushTask { moc in
Expand Down Expand Up @@ -303,6 +303,9 @@ You can find example application at [Example](/Example/) directory, which has be
* **refresh** button calls `pull` to fetch data from Cloud. That is only useful for simulators because Simulator unable to receive push notifications
* Use [CloudKit dashboard](https://icloud.developer.apple.com/dashboard/) to make changes and see it at application, and make change in application and see ones in dashboard. Don't forget to refresh dashboard's page because it doesn't update data on-the-fly.

## Example app using Cacheable Assets
[MediaBook](https://github.com/deeje/MediaBook) is a production-level iOS app being developed, which demonstrates how to handle cacheable assets in collection views.

## Tests
CloudKit objects can't be mocked up, that's why there are 2 different types of tests:

Expand Down
102 changes: 58 additions & 44 deletions Source/Classes/Caching/CloudCoreCacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,15 @@ import Network
class CloudCoreCacheManager: NSObject {

private let persistentContainer: NSPersistentContainer
private let backgroundContext: NSManagedObjectContext
private let processContext: NSManagedObjectContext
private let container: CKContainer
private let cacheableClassNames: [String]

private var frcs: [NSFetchedResultsController<NSManagedObject>] = []

public init(persistentContainer: NSPersistentContainer) {
public init(persistentContainer: NSPersistentContainer, processContext: NSManagedObjectContext) {
self.persistentContainer = persistentContainer

let backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.automaticallyMergesChangesFromParent = true
backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
self.backgroundContext = backgroundContext
self.processContext = processContext

self.container = CloudCore.config.container

Expand All @@ -41,7 +37,7 @@ class CloudCoreCacheManager: NSObject {

super.init()

restoreDanglingOperations()
restartOperations()
configureObservers()
}

Expand Down Expand Up @@ -80,7 +76,7 @@ class CloudCoreCacheManager: NSObject {
}

private func configureObservers() {
let context = backgroundContext
let context = processContext

context.perform {
for name in self.cacheableClassNames {
Expand Down Expand Up @@ -109,18 +105,20 @@ class CloudCoreCacheManager: NSObject {
}
}

func restoreDanglingOperations() {
let context = backgroundContext
func restartOperations() {
let context = processContext

context.perform {
for name in self.cacheableClassNames {
// restore existing ops
// retart new & existing ops
let upload = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.upload.rawValue)
let uploading = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.uploading.rawValue)
let download = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.download.rawValue)
let downloading = NSPredicate(format: "%K == %@", "cacheStateRaw", CacheState.downloading.rawValue)
let existing = NSCompoundPredicate(orPredicateWithSubpredicates: [uploading, downloading])
let newOrExisting = NSCompoundPredicate(orPredicateWithSubpredicates: [upload, uploading, download, downloading])
let restoreRequest = NSFetchRequest<NSManagedObject>(entityName: name)
restoreRequest.predicate = existing
if let cacheables = try? context.fetch(restoreRequest) as? [CloudCoreCacheable] {
restoreRequest.predicate = newOrExisting
if let cacheables = try? context.fetch(restoreRequest) as? [CloudCoreCacheable], !cacheables.isEmpty {
self.process(cacheables: cacheables)
}

Expand All @@ -130,7 +128,7 @@ class CloudCoreCacheManager: NSObject {
let failedToUpload = NSCompoundPredicate(orPredicateWithSubpredicates: [hasError, isLocal])
let restartRequest = NSFetchRequest<NSManagedObject>(entityName: name)
restartRequest.predicate = failedToUpload
if let cacheables = try? context.fetch(restartRequest) as? [CloudCoreCacheable] {
if let cacheables = try? context.fetch(restartRequest) as? [CloudCoreCacheable], !cacheables.isEmpty {
let cacheableIDs = cacheables.map { $0.objectID }
self.update(cacheableIDs) { cacheable in
cacheable.lastErrorMessage = nil
Expand Down Expand Up @@ -161,18 +159,23 @@ class CloudCoreCacheManager: NSObject {
return foundOperation
}

func longLivedConfiguration() -> CKOperation.Configuration {
func longLivedConfiguration(qos: QualityOfService) -> CKOperation.Configuration {
let configuration = CKOperation.Configuration()
configuration.container = container
configuration.isLongLived = true
configuration.qualityOfService = .utility
configuration.qualityOfService = qos

return configuration
}

func upload(cacheableID: NSManagedObjectID) {
// we've been asked to retry later
if let date = CloudCore.pauseUntil,
date.timeIntervalSinceNow > 0
{ return }

let container = container
let context = backgroundContext
let context = processContext

context.perform {
guard let cacheable = try? context.existingObject(with: cacheableID) as? CloudCoreCacheable else { return }
Expand All @@ -190,49 +193,60 @@ class CloudCoreCacheManager: NSObject {
record["remoteStatusRaw"] = RemoteStatus.available.rawValue

modifyOp = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
modifyOp.configuration = self.longLivedConfiguration()
modifyOp.configuration = self.longLivedConfiguration(qos: .utility)
modifyOp.savePolicy = .changedKeys

cacheable.operationID = modifyOp.operationID
}

modifyOp.perRecordProgressBlock = { record, progress in
self.update([cacheableID]) { cacheable in
cacheable.uploadProgress = progress
if progress > cacheable.uploadProgress {
cacheable.uploadProgress = progress
}
}
}
modifyOp.perRecordCompletionBlock = { record, error in
self.update([cacheableID]) { cacheable in
cacheable.uploadProgress = 0
cacheable.cacheState = (error == nil) ? .cached : .local
cacheable.remoteStatus = (error == nil) ? .available : .pending
cacheable.lastErrorMessage = error?.localizedDescription
}

if let error = error {
CloudCore.delegate?.error(error: error, module: .cacheToCloud)

if let cloudError = error as? CKError,
cloudError.code == .requestRateLimited || cloudError.code == .zoneBusy,
let number = cloudError.userInfo[CKErrorRetryAfterKey] as? NSNumber
{
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(number.intValue)) {
self.upload(cacheableID: cacheableID)
}
CloudCore.pauseUntil = Date(timeIntervalSinceNow: number.doubleValue)
}
}
}
modifyOp.modifyRecordsCompletionBlock = { records, recordIDs, error in }
modifyOp.longLivedOperationWasPersistedBlock = { }
container.privateCloudDatabase.add(modifyOp)
if !modifyOp.isExecuting {
container.privateCloudDatabase.add(modifyOp)
}

cacheable.cacheState = .uploading
try? context.save()
if cacheable.cacheState != .uploading {
cacheable.cacheState = .uploading
}
if context.hasChanges {
try? context.save()
}
}
}

func download(cacheableID: NSManagedObjectID) {
// we've been asked to retry later
if let date = CloudCore.pauseUntil,
date.timeIntervalSinceNow > 0
{ return }

let container = container
let context = backgroundContext
let context = processContext

context.perform {
guard let cacheable = try? context.existingObject(with: cacheableID) as? CloudCoreCacheable else { return }
Expand All @@ -247,15 +261,17 @@ class CloudCoreCacheManager: NSObject {
guard let record = try? cacheable.restoreRecordWithSystemFields(for: .private) else { return }

fetchOp = CKFetchRecordsOperation(recordIDs: [record.recordID])
fetchOp.configuration = self.longLivedConfiguration()
fetchOp.configuration = self.longLivedConfiguration(qos: .userInitiated)
fetchOp.desiredKeys = [cacheable.assetFieldName]

cacheable.operationID = fetchOp.operationID
}

fetchOp.perRecordProgressBlock = { record, progress in
self.update([cacheableID]) { cacheable in
cacheable.downloadProgress = progress
if progress > cacheable.downloadProgress {
cacheable.downloadProgress = progress
}
}
}
fetchOp.perRecordCompletionBlock = { record, recordID, error in
Expand All @@ -277,32 +293,30 @@ class CloudCoreCacheManager: NSObject {
CloudCore.delegate?.error(error: error, module: .cacheFromCloud)

if let cloudError = error as? CKError,
cloudError.code == .requestRateLimited || cloudError.code == .zoneBusy,
let number = cloudError.userInfo[CKErrorRetryAfterKey] as? NSNumber
{
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(number.intValue)) {
self.download(cacheableID: cacheableID)
}
CloudCore.pauseUntil = Date(timeIntervalSinceNow: number.doubleValue)
}
}
}
fetchOp.longLivedOperationWasPersistedBlock = { }
container.privateCloudDatabase.add(fetchOp)
if !fetchOp.isExecuting {
container.privateCloudDatabase.add(fetchOp)
}

cacheable.cacheState = .downloading
try? context.save()
if cacheable.cacheState != .downloading {
cacheable.cacheState = .downloading
}
if context.hasChanges {
try? context.save()
}
}
}

func unload(cacheableID: NSManagedObjectID) {
let context = backgroundContext

context.perform {
guard let cacheable = try? context.existingObject(with: cacheableID) as? CloudCoreCacheable else { return }

update([cacheableID]) { cacheable in
cacheable.removeLocal()
cacheable.cacheState = .remote
try? context.save()
}
}

Expand Down
41 changes: 35 additions & 6 deletions Source/Classes/CloudCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ open class CloudCore {

// MARK: - Properties

private(set) static var processContext: NSManagedObjectContext!

private(set) static var coreDataObserver: CoreDataObserver?
private(set) static var cacheManager: CloudCoreCacheManager?
public static var isOnline: Bool {
Expand Down Expand Up @@ -85,29 +87,56 @@ open class CloudCore {
return q
}()

// if CloudKit says to retry later…
private static var pauseTimer: Timer?
static var pauseUntil: Date? {
didSet {
DispatchQueue.main.async {
CloudCore.pauseTimer?.invalidate()
if let fireDate = CloudCore.pauseUntil {
let interval = fireDate.timeIntervalSinceNow
print("pausing for \(interval) seconds")
CloudCore.pauseTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { timer in
CloudCore.pauseUntil = nil

CloudCore.coreDataObserver?.processPersistentHistory()
CloudCore.cacheManager?.restartOperations()
}
}
}
}
}

// MARK: - Methods

/// Enable CloudKit and Core Data synchronization
///
/// - Parameters:
/// - container: `NSPersistentContainer` that will be used to save data
public static func enable(persistentContainer container: NSPersistentContainer) {
public static func enable(persistentContainer: NSPersistentContainer) {
// share a MOC between CoreDataObserver and CacheManager
let processContext = persistentContainer.newBackgroundContext()
processContext.name = "CloudCoreProcess"
processContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
processContext.automaticallyMergesChangesFromParent = true
self.processContext = processContext

// Listen for local changes
let observer = CoreDataObserver(container: container)
let observer = CoreDataObserver(persistentContainer: persistentContainer, processContext: processContext)
observer.delegate = self.delegate
observer.start()
self.coreDataObserver = observer

self.cacheManager = CloudCoreCacheManager(persistentContainer: container)
self.cacheManager = CloudCoreCacheManager(persistentContainer: persistentContainer, processContext: processContext)

// Subscribe (subscription may be outdated/removed)
let subscribeOperation = SubscribeOperation()
subscribeOperation.errorBlock = {
handle(subscriptionError: $0, container: container)
handle(subscriptionError: $0, container: persistentContainer)
}

// Fetch updated data (e.g. push notifications weren't received)
let pullOperation = PullChangesOperation(persistentContainer: container)
let pullOperation = PullChangesOperation(persistentContainer: persistentContainer)
pullOperation.errorBlock = {
self.delegate?.error(error: $0, module: .some(.pullFromCloud))
}
Expand Down Expand Up @@ -248,7 +277,7 @@ open class CloudCore {
// Zone wasn't found, we need to create it
self.queue.cancelAllOperations()

let setupOperation = SetupOperation(container: container, uploadAllData: !(coreDataObserver?.usePersistentHistoryForPush)!)
let setupOperation = SetupOperation(container: container, uploadAllData: true) // arg, why is this a question?!

// for completeness, pull again
let pullOperation = PullChangesOperation(persistentContainer: container)
Expand Down
Loading

0 comments on commit 17a0b8d

Please sign in to comment.