Retry backup failures.

pull/1/head
Matthew Chen 7 years ago
parent 05db8e3f7f
commit cf13a780e9

@ -13,7 +13,7 @@ import CloudKit
static let signalBackupRecordType = "signalBackup" static let signalBackupRecordType = "signalBackup"
static let manifestRecordName = "manifest" static let manifestRecordName = "manifest"
static let payloadKey = "payload" static let payloadKey = "payload"
static let maxImmediateRetries = 5 static let maxRetries = 5
private class func recordIdForTest() -> String { private class func recordIdForTest() -> String {
return "test-\(NSUUID().uuidString)" return "test-\(NSUUID().uuidString)"
@ -25,6 +25,12 @@ import CloudKit
return privateDatabase return privateDatabase
} }
private class func invalidServiceResponseError() -> Error {
return OWSErrorWithCodeDescription(.backupFailure,
NSLocalizedString("BACKUP_EXPORT_ERROR_INVALID_CLOUDKIT_RESPONSE",
comment: "Error indicating that the app received an invalid response from CloudKit."))
}
// MARK: - Upload // MARK: - Upload
@objc @objc
@ -101,7 +107,7 @@ import CloudKit
success: @escaping (String) -> Swift.Void, success: @escaping (String) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
saveRecordToCloud(record: record, saveRecordToCloud(record: record,
remainingRetries: maxImmediateRetries, remainingRetries: maxRetries,
success: success, success: success,
failure: failure) failure: failure)
} }
@ -137,6 +143,9 @@ import CloudKit
success: success, success: success,
failure: failure) failure: failure)
} }
case .unknownItem:
owsFail("\(self.logTag) unexpected CloudKit response.")
failure(invalidServiceResponseError())
} }
} }
} }
@ -154,6 +163,7 @@ import CloudKit
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
checkForFileInCloud(recordName: recordName, checkForFileInCloud(recordName: recordName,
remainingRetries: maxRetries,
success: { (record) in success: { (record) in
if let record = record { if let record = record {
// Record found, updating existing record. // Record found, updating existing record.
@ -187,6 +197,7 @@ import CloudKit
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
checkForFileInCloud(recordName: recordName, checkForFileInCloud(recordName: recordName,
remainingRetries: maxRetries,
success: { (record) in success: { (record) in
if record != nil { if record != nil {
// Record found, skipping save. // Record found, skipping save.
@ -218,7 +229,7 @@ import CloudKit
success: @escaping (Swift.Void) -> Swift.Void, success: @escaping (Swift.Void) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
deleteRecordFromCloud(recordName: recordName, deleteRecordFromCloud(recordName: recordName,
remainingRetries: maxImmediateRetries, remainingRetries: maxRetries,
success: success, success: success,
failure: failure) failure: failure)
} }
@ -231,7 +242,7 @@ import CloudKit
let recordID = CKRecordID(recordName: recordName) let recordID = CKRecordID(recordName: recordName)
database().delete(withRecordID: recordID) { database().delete(withRecordID: recordID) {
(record, error) in (_, error) in
let response = responseForCloudKitError(error: error, let response = responseForCloudKitError(error: error,
remainingRetries: remainingRetries, remainingRetries: remainingRetries,
@ -255,6 +266,9 @@ import CloudKit
success: success, success: success,
failure: failure) failure: failure)
} }
case .unknownItem:
owsFail("\(self.logTag) unexpected CloudKit response.")
failure(invalidServiceResponseError())
} }
} }
} }
@ -262,6 +276,7 @@ import CloudKit
// MARK: - Exists? // MARK: - Exists?
private class func checkForFileInCloud(recordName: String, private class func checkForFileInCloud(recordName: String,
remainingRetries: Int,
success: @escaping (CKRecord?) -> Swift.Void, success: @escaping (CKRecord?) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
let recordId = CKRecordID(recordName: recordName) let recordId = CKRecordID(recordName: recordName)
@ -270,29 +285,39 @@ import CloudKit
// not this record already exists. // not this record already exists.
fetchOperation.desiredKeys = [] fetchOperation.desiredKeys = []
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
if let error = error {
if let ckerror = error as? CKError { let response = responseForCloudKitError(error: error,
if ckerror.code == .unknownItem { remainingRetries: remainingRetries,
// Record not found. label: "Check for Record")
success(nil) switch response {
return case .success:
} guard let record = record else {
Logger.error("\(self.logTag) error fetching record: \(error) \(ckerror.code).") owsFail("\(self.logTag) missing fetching record.")
} else { failure(invalidServiceResponseError())
Logger.error("\(self.logTag) error fetching record: \(error).") return
} }
failure(error) // Record found.
return success(record)
} case .failureDoNotRetry(let responseError):
guard let record = record else { failure(responseError)
Logger.error("\(self.logTag) missing fetching record.") case .failureRetryAfterDelay(let retryDelay):
failure(OWSErrorWithCodeDescription(.exportBackupError, DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
NSLocalizedString("BACKUP_EXPORT_ERROR_SAVE_FILE_TO_CLOUD_FAILED", checkForFileInCloud(recordName: recordName,
comment: "Error indicating the a backup export failed to save a file to the cloud."))) remainingRetries: remainingRetries - 1,
return success: success,
failure: failure)
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
checkForFileInCloud(recordName: recordName,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
}
case .unknownItem:
// Record not found.
success(nil)
} }
// Record found.
success(record)
} }
database().add(fetchOperation) database().add(fetchOperation)
} }
@ -302,6 +327,7 @@ import CloudKit
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
checkForFileInCloud(recordName: manifestRecordName, checkForFileInCloud(recordName: manifestRecordName,
remainingRetries: maxRetries,
success: { (record) in success: { (record) in
success(record != nil) success(record != nil)
}, },
@ -317,6 +343,7 @@ import CloudKit
fetchAllRecordNamesStep(query: query, fetchAllRecordNamesStep(query: query,
previousRecordNames: [String](), previousRecordNames: [String](),
cursor: nil, cursor: nil,
remainingRetries: maxRetries,
success: success, success: success,
failure: failure) failure: failure)
} }
@ -324,6 +351,7 @@ import CloudKit
private class func fetchAllRecordNamesStep(query: CKQuery, private class func fetchAllRecordNamesStep(query: CKQuery,
previousRecordNames: [String], previousRecordNames: [String],
cursor: CKQueryCursor?, cursor: CKQueryCursor?,
remainingRetries: Int,
success: @escaping ([String]) -> Swift.Void, success: @escaping ([String]) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
@ -340,23 +368,49 @@ import CloudKit
allRecordNames.append(record.recordID.recordName) allRecordNames.append(record.recordID.recordName)
} }
queryOperation.queryCompletionBlock = { (cursor, error) in queryOperation.queryCompletionBlock = { (cursor, error) in
if let error = error {
Logger.error("\(self.logTag) error fetching all record names: \(error).") let response = responseForCloudKitError(error: error,
failure(error) remainingRetries: remainingRetries,
return label: "Fetch All Records")
} switch response {
if let cursor = cursor { case .success:
Logger.verbose("\(self.logTag) fetching more record names \(allRecordNames.count).") if let cursor = cursor {
// There are more pages of results, continue fetching. Logger.verbose("\(self.logTag) fetching more record names \(allRecordNames.count).")
fetchAllRecordNamesStep(query: query, // There are more pages of results, continue fetching.
previousRecordNames: allRecordNames, fetchAllRecordNamesStep(query: query,
cursor: cursor, previousRecordNames: allRecordNames,
success: success, cursor: cursor,
failure: failure) remainingRetries: maxRetries,
return success: success,
failure: failure)
return
}
Logger.info("\(self.logTag) fetched \(allRecordNames.count) record names.")
success(allRecordNames)
case .failureDoNotRetry(let responseError):
failure(responseError)
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
fetchAllRecordNamesStep(query: query,
previousRecordNames: allRecordNames,
cursor: cursor,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
fetchAllRecordNamesStep(query: query,
previousRecordNames: allRecordNames,
cursor: cursor,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
}
case .unknownItem:
owsFail("\(self.logTag) unexpected CloudKit response.")
failure(invalidServiceResponseError())
} }
Logger.info("\(self.logTag) fetched \(allRecordNames.count) record names.")
success(allRecordNames)
} }
database().add(queryOperation) database().add(queryOperation)
} }
@ -378,6 +432,7 @@ import CloudKit
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
downloadFromCloud(recordName: recordName, downloadFromCloud(recordName: recordName,
remainingRetries: maxRetries,
success: { (asset) in success: { (asset) in
DispatchQueue.global().async { DispatchQueue.global().async {
do { do {
@ -385,9 +440,7 @@ import CloudKit
success(data) success(data)
} catch { } catch {
Logger.error("\(self.logTag) couldn't load asset file: \(error).") Logger.error("\(self.logTag) couldn't load asset file: \(error).")
failure(OWSErrorWithCodeDescription(.exportBackupError, failure(invalidServiceResponseError())
NSLocalizedString("BACKUP_IMPORT_ERROR_DOWNLOAD_FILE_FROM_CLOUD_FAILED",
comment: "Error indicating the a backup import failed to download a file from the cloud.")))
} }
} }
}, },
@ -401,6 +454,7 @@ import CloudKit
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
downloadFromCloud(recordName: recordName, downloadFromCloud(recordName: recordName,
remainingRetries: maxRetries,
success: { (asset) in success: { (asset) in
DispatchQueue.global().async { DispatchQueue.global().async {
do { do {
@ -408,16 +462,20 @@ import CloudKit
success() success()
} catch { } catch {
Logger.error("\(self.logTag) couldn't copy asset file: \(error).") Logger.error("\(self.logTag) couldn't copy asset file: \(error).")
failure(OWSErrorWithCodeDescription(.exportBackupError, failure(invalidServiceResponseError())
NSLocalizedString("BACKUP_IMPORT_ERROR_DOWNLOAD_FILE_FROM_CLOUD_FAILED",
comment: "Error indicating the a backup import failed to download a file from the cloud.")))
} }
} }
}, },
failure: failure) failure: failure)
} }
// We return the CKAsset and not its fileUrl because
// CloudKit offers no guarantees around how long it'll
// keep around the underlying file. Presumably we can
// defer cleanup by maintaining a strong reference to
// the asset.
private class func downloadFromCloud(recordName: String, private class func downloadFromCloud(recordName: String,
remainingRetries: Int,
success: @escaping (CKAsset) -> Swift.Void, success: @escaping (CKAsset) -> Swift.Void,
failure: @escaping (Error) -> Swift.Void) { failure: @escaping (Error) -> Swift.Void) {
@ -425,25 +483,43 @@ import CloudKit
let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ]) let fetchOperation = CKFetchRecordsOperation(recordIDs: [recordId ])
// Download all keys for this record. // Download all keys for this record.
fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in fetchOperation.perRecordCompletionBlock = { (record, recordId, error) in
if let error = error {
failure(error) let response = responseForCloudKitError(error: error,
return remainingRetries: remainingRetries,
} label: "Download Record")
guard let record = record else { switch response {
case .success:
guard let record = record else {
Logger.error("\(self.logTag) missing fetching record.")
failure(invalidServiceResponseError())
return
}
guard let asset = record[payloadKey] as? CKAsset else {
Logger.error("\(self.logTag) record missing payload.")
failure(invalidServiceResponseError())
return
}
success(asset)
case .failureDoNotRetry(let responseError):
failure(responseError)
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
downloadFromCloud(recordName: recordName,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
downloadFromCloud(recordName: recordName,
remainingRetries: remainingRetries - 1,
success: success,
failure: failure)
}
case .unknownItem:
Logger.error("\(self.logTag) missing fetching record.") Logger.error("\(self.logTag) missing fetching record.")
failure(OWSErrorWithCodeDescription(.exportBackupError, failure(invalidServiceResponseError())
NSLocalizedString("BACKUP_IMPORT_ERROR_DOWNLOAD_FILE_FROM_CLOUD_FAILED",
comment: "Error indicating the a backup import failed to download a file from the cloud.")))
return
} }
guard let asset = record[payloadKey] as? CKAsset else {
Logger.error("\(self.logTag) record missing payload.")
failure(OWSErrorWithCodeDescription(.exportBackupError,
NSLocalizedString("BACKUP_IMPORT_ERROR_DOWNLOAD_FILE_FROM_CLOUD_FAILED",
comment: "Error indicating the a backup import failed to download a file from the cloud.")))
return
}
success(asset)
} }
database().add(fetchOperation) database().add(fetchOperation)
} }
@ -481,13 +557,22 @@ import CloudKit
case failureDoNotRetry(error:Error) case failureDoNotRetry(error:Error)
case failureRetryAfterDelay(retryDelay: Double) case failureRetryAfterDelay(retryDelay: Double)
case failureRetryWithoutDelay case failureRetryWithoutDelay
// This only applies to fetches.
case unknownItem
} }
private class func responseForCloudKitError(error: Error?, private class func responseForCloudKitError(error: Error?,
remainingRetries: Int, remainingRetries: Int,
label: String) -> CKErrorResponse { label: String) -> CKErrorResponse {
if let error = error as? CKError { if let error = error as? CKError {
if error.code == CKError.unknownItem {
// This is not always an error for our purposes.
Logger.verbose("\(self.logTag) \(label) unknown item.")
return .unknownItem
}
Logger.error("\(self.logTag) \(label) failed: \(error)") Logger.error("\(self.logTag) \(label) failed: \(error)")
if remainingRetries < 1 { if remainingRetries < 1 {
Logger.verbose("\(self.logTag) \(label) no more retries.") Logger.verbose("\(self.logTag) \(label) no more retries.")
return .failureDoNotRetry(error:error) return .failureDoNotRetry(error:error)

@ -39,6 +39,8 @@ typedef NS_ENUM(NSInteger, OWSErrorCode) {
OWSErrorCodeImportBackupFailed = 777417, OWSErrorCodeImportBackupFailed = 777417,
// A possibly recoverable error occured while importing a backup. // A possibly recoverable error occured while importing a backup.
OWSErrorCodeImportBackupError = 777418, OWSErrorCodeImportBackupError = 777418,
// A non-recoverable while importing or exporting a backup.
OWSErrorCodeBackupFailure = 777419,
}; };
extern NSString *const OWSErrorRecipientIdentifierKey; extern NSString *const OWSErrorRecipientIdentifierKey;

Loading…
Cancel
Save