Created
October 22, 2019 21:32
-
-
Save hyouuu/6bef3ee8a7512c3feab5893dd0c6deb6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
Since CloudKit doesn't provide a way to query all recordIDs, we should keep that info in a Meta record so that we can efficiently maintain in sync. | |
A Meta record will have a String field "recordNames", which contains a list of record names like: | |
{title}___note_{UUID}___{update}___deleted | |
{title}___note_{UUID}___{update} | |
{title}___note_{UUID}__media_{mediaUUID}___{update} | |
{title}___note_{UUID}__media_{mediaUUID}_data___{update} | |
At init we fetch the Meta to compile the recordNames, then when we write or delete records, update the Meta and upload. We listen to changes to it and update our local copy. | |
*/ | |
import CloudKit | |
final class CKSyncHandler { | |
enum RecordType: String { | |
case userSettings = "UserSettings" // jsonData to store options & flags, and avatarData for avatar | |
case meta = "Meta" // See above for Meta content definitions | |
case note = "Note" | |
case media = "Media" | |
case data = "Data" | |
} | |
// MARK: UserSettings fields | |
let userSettingsRecordName = "userSettings" | |
let jsonDataField = "jsonData" | |
let avatarDataField = "avatarData" // currently not used - might want to use User altogether | |
let avatarUpdateField = "avatarUpdate" // currently not used | |
// MARK: Meta fields | |
let metaRecordName = "meta" | |
let metaRecordNamesField = "recordNames" | |
let metaDeletedRecordInfosField = "deletedRecordInfos" | |
// MARK: Consts | |
let deletedRecordInfoRetainCount = 1000 | |
let minMetaFetchInterval = 10.t | |
// If retry delay increased to beyond this, fail the request | |
let maxRetryDelay = 10.t | |
let assocProvider = SyncProvider.cloudKit | |
let db: CKDatabase = CKContainer(identifier: "iCloud.hyouuu.pendo").privateCloudDatabase | |
// MARK: States | |
var lastMetaFetchTimestamp = 0.t | |
var lastCKQuotaAlertDate: Date? | |
// Whether the first fetch fininshed, before which no user write operations are performed | |
var metaRecordFirstFetched = false | |
// Generate from & convert to Meta's recordNames & deletedRecordInfos | |
var metaRecordInfoToUpdates = [String: Int]() | |
// This will only keep the last 1000 records when reading from Cloud, and any records before that are dropped | |
var deletedRecordInfos = OrderedSet<String>() | |
var isUpserting = false | |
var resetMetaRecordFetchedCount = 0 | |
var notesQueryOp: CKQueryOperation? | |
var mediasQueryOp: CKQueryOperation? | |
// MARK: Lifecycle | |
weak var syncerDelegate: SyncerDelegate? | |
// MARK: Helper | |
func isSyncStateValid() -> Bool { | |
guard let delegate = syncerDelegate else { | |
er("no syncerDelegate") | |
return false | |
} | |
return delegate.isSyncStateValid() | |
} | |
// MARK: CK Actions to Propogate to Local | |
func recordUpserted(_ recordInfo: String, obj: BaseR) { | |
spr("CK recordUpserted \(recordInfo)") | |
toWrite { | |
self.metaRecordInfoToUpdates[recordInfo] = obj.updateForRecord(recordInfo) | |
} | |
delayToBg { | |
self.syncerDelegate?.recordUpserted(from: self.assocProvider, recordInfo: recordInfo, obj: obj) | |
} | |
} | |
func recordDeleted(_ recordInfo: String) { | |
spr("CK recordDeleted \(recordInfo)") | |
toWrite { | |
self.metaRecordInfoToUpdates.removeValue(forKey: recordInfo) | |
self.deletedRecordInfos.append(recordInfo) | |
} | |
delayToBg { | |
self.syncerDelegate?.recordDeleted(from: self.assocProvider, recordInfo: recordInfo) | |
} | |
} | |
// MARK: Query Op | |
// Query Op fetched records only contains desiredKeys: update & dataUpdate | |
// Don't call updateMeta as this is called from resetMeta | |
func processQueryOpFetchedRecord(_ record: CKRecord) { | |
let recordInfo = record.recordID.recordName | |
toWrite { | |
self.resetMetaRecordFetchedCount += 1 | |
#if DEBUG | |
if recordInfo.contains(debugSyncWatchId) { | |
pr("watch record:\(record)") | |
pr() | |
} | |
#endif | |
var update = 0 | |
if let u = record["update"] as? Int { | |
update = u | |
} else { | |
er("CK processQueryOpFetchedRecord no update for \(recordInfo)", .cloudKit) | |
} | |
// Update record for both Note & Media meta | |
self.metaRecordInfoToUpdates[recordInfo] = update | |
guard let info = Record.Info(recordInfo) else { | |
er("CK processQueryOpFetchedRecord recordInfo:\(recordInfo) can't make info", .cloudKit) | |
return | |
} | |
// Only need additional actions to gen data recordInfo for media | |
guard let _ = info.mediaID else { return } | |
let dataUpdate = record["dataUpdate"] as? Int ?? 0 | |
if dataUpdate == 0 { | |
er("CK processQueryOpFetchedRecord Media dataUpdate shouldn't be 0 - record:\(record)", .cloudKit) | |
} | |
let dataRecordInfo: String | |
if info.isData { | |
er("CK processQueryOpFetchedRecord not expecting diret data record", .cloudKit) | |
dataRecordInfo = recordInfo | |
} else { | |
dataRecordInfo = Record.makeDataRecordInfo(from: recordInfo) | |
} | |
self.metaRecordInfoToUpdates[dataRecordInfo] = dataUpdate | |
} | |
} | |
// MARK: Meta | |
// Called from listRecordNames frequently | |
func fetchMeta(_ src: String, retryDelay: Double = 0.5, completion: @escaping SuccessBlock) { | |
spr("CK fetchMeta src:\(src) retryDelay: \(retryDelay)") | |
guard Date().t - lastMetaFetchTimestamp >= minMetaFetchInterval else { | |
spr("CK fetchMeta within 10 secs - use existing metaRecordInfoToUpdates") | |
completion(true) | |
return | |
} | |
guard isSyncStateValid() else { completion(false); return } | |
db.fetch(withRecordID: CKRecord.ID(recordName: metaRecordName)) { [weak self] record, err in | |
guard let s = self else { completion(false); return } | |
spr("CK fetchMeta src:\(src) db.fetch hasRecord? \(record != nil) err:\(String(describing: err))") | |
// Unless "Not Found", deal with the error (possibly resetMeta) and return | |
if let err = err, !s.isRecordNotFoundErr(err) { | |
let shouldRetry = s.handleErr(err, src: "fetchMeta") | |
if shouldRetry { | |
guard retryDelay <= s.maxRetryDelay else { completion(false); return } | |
let msg = "CK fetchMeta err:\(err.desc) retryDelay:\(retryDelay)" | |
spr(msg) | |
delayToBg(retryDelay) { | |
s.fetchMeta(msg, retryDelay: nextRetryDelay(retryDelay), completion: completion) | |
} | |
return | |
} | |
if let err = err as? CKError, | |
(err.code == CKError.notAuthenticated || err.code == CKError.quotaExceeded) | |
{ | |
completion(false) | |
} else { | |
s.resetMeta("CK fetchMeta err:\(err.desc)", completion: completion) | |
} | |
return | |
} | |
// If no meta record yet, create one | |
guard let record = record else { | |
if !isVeryFirstTimeRun() { | |
er("Expect meta to exist at this point", .cloudKit) | |
} | |
s.updateCKMeta("CK fetchMeta no existing - make new meta", forceUpdate: true) { [weak self] finished in | |
delayToBg(retryDelay) { | |
self?.fetchMeta("fetchMeta no meta record afer updateCKMeta", | |
retryDelay: nextRetryDelay(retryDelay), | |
completion: completion) | |
} | |
} | |
return | |
} | |
// Fetched meta - process | |
s.updateMetaRecordInfos(record[s.metaRecordNamesField] as? String ?? "", | |
metaDeletedRecordInfos:record[s.metaDeletedRecordInfosField] as? String ?? "") | |
s.lastMetaFetchTimestamp = Date().t | |
s.metaRecordFirstFetched = true | |
spr("CK fetchMeta completed") | |
completion(true) | |
} | |
} | |
private func updateMetaRecordInfos(_ metaRecordNames: String, metaDeletedRecordInfos: String) { | |
// Don't use .newlines since \r would also be considered that | |
let recordNames = metaRecordNames.components(separatedBy: nl) | |
let deletedRecordInfos = metaDeletedRecordInfos.components(separatedBy: nl).suffix(deletedRecordInfoRetainCount) | |
spr("CK updateMetaRecordInfos recordNames count:\(recordNames.count) deleted count:\(deletedRecordInfos.count)") | |
toWrite { | |
self.metaRecordInfoToUpdates.removeAll() | |
for recordName in recordNames { | |
guard recordName.hasVal else { continue } | |
// Deprecating the deleted part in favor of metaDeletedRecordInfos | |
guard let (recordInfo, update, deleted) = Record.parseName(recordName) else { | |
er("CK updateMetaRecordInfos unparsable recordName \(recordName)", .cloudKit) | |
continue | |
} | |
if deleted { | |
self.deletedRecordInfos.append(recordInfo) | |
} else { | |
self.metaRecordInfoToUpdates[recordInfo] = update | |
} | |
} | |
for recordInfo in deletedRecordInfos { | |
self.deletedRecordInfos.append(recordInfo) | |
} | |
} | |
#if DEBUG | |
pr("###") | |
// pr("CK updated metaRecordInfoToUpdates:") | |
// pr("\(metaRecordInfoToUpdates)") | |
pr("###") | |
#endif | |
} | |
// When a Media changes, multiple record will change - Data, Media & Note. | |
// We don't want to update Meta 3 times, so instead we schedule a delayed update. | |
var updateCKMetaCancelBlock: Block? | |
// However, if we're inserting 1000 records, we still want to have a chance to update meta from time to time | |
let updateCKMetaCancelLimit = 30 | |
var updateCKMetaCancelCount = 0 | |
func scheduleUpdateCKMeta(_ src: String) { | |
spr("CK scheduleUpdateCKMeta src:\(src)") | |
if let cancelBlock = updateCKMetaCancelBlock { | |
if updateCKMetaCancelCount < updateCKMetaCancelLimit { | |
updateCKMetaCancelCount += 1 | |
pr("CK schedule updateCKMeta from \(src) cancelling with cancelCount:\(updateCKMetaCancelCount)") | |
cancelBlock() | |
} else { | |
pr("CK schedule updateCKMeta from \(src) NOT cancelling with cancelCount:\(updateCKMetaCancelCount)") | |
updateCKMetaCancelCount = 0 | |
} | |
} else { | |
updateCKMetaCancelCount = 0 | |
} | |
updateCKMetaCancelBlock = delayToBg(2) { | |
self.updateCKMeta(src, forceUpdate: false, completion: nil) | |
self.updateCKMetaCancelBlock = nil | |
} | |
} | |
// Update CK Meta from metaRecordInfoToUpdates & deletedRecordInfos | |
// forceUpdate is used when no meta exists at the moment, which ignores checking metaRecordFirstFetched | |
func updateCKMeta(_ src: String, forceUpdate: Bool, retryDelay: TimeInterval = 0.5, completion: SuccessBlock?) { | |
spr("CK updateCKMeta src:\(src) forceUpdate:\(forceUpdate) retryDelay:\(retryDelay)") | |
guard forceUpdate || metaRecordFirstFetched else { | |
let msg = "CK updateCKMeta without metaRecordFirstFetched - delay" | |
spr(msg) | |
delayToBg(retryDelay) { | |
self.updateCKMeta(src + " > " + msg, | |
forceUpdate: forceUpdate, | |
retryDelay: nextRetryDelay(retryDelay), | |
completion: completion) | |
} | |
return | |
} | |
guard isSyncStateValid() else { completion?(false); return } | |
var joinedRecordNames = "" | |
var joinedDeletedRecordInfos = "" | |
read { | |
for (recordInfo, update) in self.metaRecordInfoToUpdates { | |
let title = "" | |
joinedRecordNames += Record.makeName(title, recordInfo: recordInfo, update: update) + nl | |
} | |
joinedDeletedRecordInfos = self.deletedRecordInfos.joined(separator: nl) | |
#if DEBUG | |
pr("CK updateCKMeta metaRecordInfoToUpdates count:\(self.metaRecordInfoToUpdates.count)") | |
pr("CK updateCKMeta deletedRecordInfos count:\(self.metaRecordInfoToUpdates.count)") | |
#endif | |
} | |
let metaRecordID = CKRecord.ID(recordName: metaRecordName) | |
spr("CK updateCKMeta fetch \(metaRecordID)") | |
// Fetch meta record to update | |
db.fetch(withRecordID: metaRecordID) { [weak self] fetchedRecord, err in | |
guard let s = self else { completion?(false); return } | |
spr("CK updateCKMeta did fetch with error:\(String(describing: err?.desc))") | |
// Unless "Not Found", deal with the error | |
if let err = err, !s.isRecordNotFoundErr(err) { | |
let shouldRetry = s.handleErr(err, src: "updateCKMeta") | |
if shouldRetry { | |
guard retryDelay <= s.maxRetryDelay else { completion?(false); return } | |
spr("updateCKMeta fetch retryDelay:\(retryDelay)") | |
delayToBg(retryDelay) { | |
s.updateCKMeta(src, | |
forceUpdate: forceUpdate, | |
retryDelay: nextRetryDelay(retryDelay), | |
completion: completion) | |
} | |
return | |
} | |
completion?(false) | |
return | |
} | |
var record = fetchedRecord | |
// Create if none existing | |
if record == nil { | |
record = CKRecord(recordType: RecordType.meta.rawValue, recordID: metaRecordID) | |
} | |
guard let metaRecord = record else { | |
er("CK updateCKMeta cannot init meta CKRecord", .cloudKit) | |
completion?(false) | |
return | |
} | |
metaRecord[s.metaRecordNamesField] = joinedRecordNames as NSString | |
metaRecord[s.metaDeletedRecordInfosField] = joinedDeletedRecordInfos as NSString | |
// Equal to a fetchMeta | |
s.lastMetaFetchTimestamp = Date().t | |
s.metaRecordFirstFetched = true | |
spr("CK updateCKMeta saving...") | |
s.db.save(metaRecord) { [weak self] savedRecord, err in | |
guard let s = self else { completion?(false); return } | |
guard let err = err else { | |
spr("CK updateCKMeta saved successfully") | |
completion?(true) | |
return | |
} | |
let shouldRetry = s.handleErr(err, src: "updateCKMeta saveRecord") | |
if shouldRetry { | |
guard retryDelay <= s.maxRetryDelay else { completion?(false); return } | |
spr("updateCKMeta save retryDelay:\(retryDelay)") | |
delayToBg(retryDelay) { | |
s.updateCKMeta(src, | |
forceUpdate: forceUpdate, | |
retryDelay: nextRetryDelay(retryDelay), | |
completion: completion) | |
} | |
return | |
} | |
completion?(false) | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment