Batch backup exports.

This commit is contained in:
Matthew Chen 2018-11-30 16:07:23 -05:00
parent 163c467480
commit c1ac5c1872
2 changed files with 147 additions and 36 deletions

View File

@ -57,6 +57,16 @@ import PromiseKit
recordType: signalBackupRecordType)
}
// "Ephemeral" files are specific to this backup export and will always need to
// be saved. For example, a complete image of the database is exported each time.
// We wouldn't want to overwrite previous images until the entire backup export is
// complete.
@objc
public class func recordNameForEphemeralFile(recipientId: String,
label: String) -> String {
return "\(recordNamePrefix(forRecipientId: recipientId))ephemeral-\(label)-\(NSUUID().uuidString)"
}
// "Ephemeral" files are specific to this backup export and will always need to
// be saved. For example, a complete image of the database is exported each time.
// We wouldn't want to overwrite previous images until the entire backup export is
@ -73,7 +83,7 @@ import PromiseKit
public class func saveEphemeralFileToCloud(recipientId: String,
label: String,
fileUrl: URL) -> Promise<String> {
let recordName = "\(recordNamePrefix(forRecipientId: recipientId))ephemeral-\(label)-\(NSUUID().uuidString)"
let recordName = recordNameForEphemeralFile(recipientId: recipientId, label: label)
return saveFileToCloud(fileUrl: fileUrl,
recordName: recordName,
recordType: signalBackupRecordType)
@ -181,11 +191,10 @@ import PromiseKit
@objc
public class func saveFileToCloudObjc(fileUrl: URL,
recordName: String,
recordType: String) -> AnyPromise {
recordName: String) -> AnyPromise {
return AnyPromise(saveFileToCloud(fileUrl: fileUrl,
recordName: recordName,
recordType: recordType))
recordType: signalBackupRecordType))
}
public class func saveFileToCloud(fileUrl: URL,
@ -216,7 +225,7 @@ import PromiseKit
return Promise { resolver in
let saveOperation = CKModifyRecordsOperation(recordsToSave: [record ], recordIDsToDelete: nil)
saveOperation.modifyRecordsCompletionBlock = { (records, recordIds, error) in
saveOperation.modifyRecordsCompletionBlock = { (_, _, error) in
let outcome = outcomeForCloudKitError(error: error,
remainingRetries: remainingRetries,
@ -264,6 +273,99 @@ import PromiseKit
}
}
@objc
public class func record(forFileUrl fileUrl: URL,
recordName: String) -> CKRecord {
let recordType = signalBackupRecordType
let recordID = CKRecordID(recordName: recordName)
let record = CKRecord(recordType: recordType, recordID: recordID)
let asset = CKAsset(fileURL: fileUrl)
record[payloadKey] = asset
return record
}
@objc
public class func saveRecordsToCloudObjc(records: [CKRecord]) -> AnyPromise {
return AnyPromise(saveRecordsToCloud(records: records))
}
public class func saveRecordsToCloud(records: [CKRecord]) -> Promise<Void> {
return saveRecordsToCloud(records: records,
remainingRetries: maxRetries)
}
private class func saveRecordsToCloud(records: [CKRecord],
remainingRetries: Int) -> Promise<Void> {
let recordNames = records.map { (record) in
return record.recordID.recordName
}
Logger.verbose("recordNames \(recordNames)")
return Promise { resolver in
let saveOperation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
saveOperation.modifyRecordsCompletionBlock = { (savedRecords: [CKRecord]?, _, error) in
let retry = {
// Only retry records which didn't already succeed.
var savedRecordNames = [String]()
if let savedRecords = savedRecords {
savedRecordNames = savedRecords.map { (record) in
return record.recordID.recordName
}
}
let retryRecords = records.filter({ (record) in
return !savedRecordNames.contains(record.recordID.recordName)
})
saveRecordsToCloud(records: retryRecords,
remainingRetries: remainingRetries - 1)
.done { _ in
resolver.fulfill(())
}.catch { (error) in
resolver.reject(error)
}.retainUntilComplete()
}
let outcome = outcomeForCloudKitError(error: error,
remainingRetries: remainingRetries,
label: "Save Record")
switch outcome {
case .success:
resolver.fulfill(())
case .failureDoNotRetry(let outcomeError):
resolver.reject(outcomeError)
case .failureRetryAfterDelay(let retryDelay):
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + retryDelay, execute: {
retry()
})
case .failureRetryWithoutDelay:
DispatchQueue.global().async {
retry()
}
case .unknownItem:
owsFailDebug("unexpected CloudKit response.")
resolver.reject(invalidServiceResponseError())
}
}
saveOperation.isAtomic = false
saveOperation.savePolicy = .allKeys
// TODO: use perRecordProgressBlock and perRecordCompletionBlock.
// open var perRecordProgressBlock: ((CKRecord, Double) -> Void)?
// open var perRecordCompletionBlock: ((CKRecord, Error?) -> Void)?
// These APIs are only available in iOS 9.3 and later.
if #available(iOS 9.3, *) {
saveOperation.isLongLived = true
saveOperation.qualityOfService = .background
}
database().add(saveOperation)
}
}
// Compare:
// * An "upsert" creates a new record if none exists and
// or updates if there is an existing record.

View File

@ -18,6 +18,8 @@
#import <SignalServiceKit/TSMessage.h>
#import <SignalServiceKit/TSThread.h>
@import CloudKit;
NS_ASSUME_NONNULL_BEGIN
@class OWSAttachmentExport;
@ -738,33 +740,39 @@ NS_ASSUME_NONNULL_BEGIN
// This method returns YES IFF "work was done and there might be more work to do".
- (AnyPromise *)saveDatabaseFilesToCloud
{
AnyPromise *promise = [AnyPromise promiseWithValue:@(1)];
// AnyPromise *promise = [AnyPromise promiseWithValue:@(1)];
// We need to preserve ordering of database shards.
for (OWSBackupExportItem *item in self.unsavedDatabaseItems) {
if (self.isComplete) {
return [AnyPromise promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")];
}
NSArray<OWSBackupExportItem *> *items = [self.unsavedDatabaseItems copy];
NSMutableArray<CKRecord *> *records = [NSMutableArray new];
for (OWSBackupExportItem *item in items) {
OWSAssertDebug(item.encryptedItem.filePath.length > 0);
promise
= promise
.thenInBackground(^{
if (self.isComplete) {
return [AnyPromise
promiseWithValue:OWSBackupErrorWithDescription(@"Backup export no longer active.")];
}
return [OWSBackupAPI
saveEphemeralFileToCloudObjcWithRecipientId:self.recipientId
label:@"database"
fileUrl:[NSURL
fileURLWithPath:item.encryptedItem.filePath]];
})
.thenInBackground(^(NSString *recordName) {
item.recordName = recordName;
[self.savedDatabaseItems addObject:item];
});
NSString *recordName =
[OWSBackupAPI recordNameForEphemeralFileWithRecipientId:self.recipientId label:@"database"];
CKRecord *record =
[OWSBackupAPI recordForFileUrl:[NSURL fileURLWithPath:item.encryptedItem.filePath] recordName:recordName];
[records addObject:record];
}
[self.unsavedDatabaseItems removeAllObjects];
return promise;
// TODO: Expose progress.
return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:records].thenInBackground(^(NSString *recordName) {
OWSAssertDebug(items.count == records.count);
NSUInteger count = MIN(items.count, records.count);
for (NSUInteger i = 0; i < count; i++) {
OWSBackupExportItem *item = items[i];
CKRecord *record = records[i];
OWSAssertDebug(record.recordID.recordName.length > 0);
item.recordName = record.recordID.recordName;
}
[self.savedDatabaseItems addObjectsFromArray:items];
[self.unsavedDatabaseItems removeObjectsInArray:items];
});
}
// This method returns YES IFF "work was done and there might be more work to do".
@ -907,15 +915,16 @@ NS_ASSUME_NONNULL_BEGIN
OWSBackupExportItem *exportItem = [OWSBackupExportItem new];
exportItem.encryptedItem = encryptedItem;
return [OWSBackupAPI saveEphemeralFileToCloudObjcWithRecipientId:self.recipientId
label:@"local-profile-avatar"
fileUrl:[NSURL fileURLWithPath:encryptedItem.filePath]]
.thenInBackground(^(NSString *recordName) {
exportItem.recordName = recordName;
self.localProfileAvatarItem = exportItem;
NSString *recordName =
[OWSBackupAPI recordNameForEphemeralFileWithRecipientId:self.recipientId label:@"local-profile-avatar"];
CKRecord *record =
[OWSBackupAPI recordForFileUrl:[NSURL fileURLWithPath:encryptedItem.filePath] recordName:recordName];
return [OWSBackupAPI saveRecordsToCloudObjcWithRecords:@[ record ]].thenInBackground(^{
exportItem.recordName = recordName;
self.localProfileAvatarItem = exportItem;
return [AnyPromise promiseWithValue:@(1)];
});
return [AnyPromise promiseWithValue:@(1)];
});
}
- (AnyPromise *)saveManifestFileToCloud