Skip to content

Instantly share code, notes, and snippets.

@hyouuu
Created October 22, 2019 21:32
Show Gist options
  • Save hyouuu/6bef3ee8a7512c3feab5893dd0c6deb6 to your computer and use it in GitHub Desktop.
Save hyouuu/6bef3ee8a7512c3feab5893dd0c6deb6 to your computer and use it in GitHub Desktop.
/*
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