Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,161 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
import Storage
private let log = Logger.syncLogger
let BookmarksStorageVersion = 2
/**
* This is like a synchronizer, but it downloads records bit by bit, eventually
* notifying that the local storage is up to date with the server contents.
*
* Because batches might be separated over time, it's possible for the server
* state to change between calls. These state changes might include:
*
* 1. New changes arriving. This is fairly routine, but it's worth noting that
* changes might affect existing records that have been batched!
* 2. Wipes. The collection (or the server as a whole) might be deleted. This
* should be accompanied by a change in syncID in meta/global; it's the caller's
* responsibility to detect this.
* 3. A storage format change. This should be unfathomably rare, but if it happens
* we must also be prepared to discard our existing batched data.
* 4. TTL expiry. We need to do better about TTL handling in general, but here
* we might find that a downloaded record is no longer live by the time we
* come to apply it! This doesn't apply to bookmark records, so we will ignore
* it for the moment.
*
* Batch downloading without continuation tokens is achieved as follows:
*
* * A minimum timestamp is established. This starts as zero.
* * A fetch is issued against the server for records changed since that timestamp,
* ordered by modified time ascending, and limited to the batch size.
* * If the batch is complete, we flush it to storage and advance the minimum
* timestamp to just before the newest record in the batch. This ensures that
* a divided set of records with the same modified times will be downloaded
* entirely so long as the set is never larger than the batch size.
* * Iterate until we determine that there are no new records to fetch.
*
* Batch downloading with continuation tokens is much easier:
*
* * A minimum timestamp is established.
* * Make a request with limit=N.
* * Look for an X-Weave-Next-Offset header. Supply that in the next request.
* Also supply X-If-Unmodified-Since to avoid missed modifications.
*
* We do the latter, because we only support Sync 1.5. The use of the offset
* allows us to efficiently process batches, particularly those that contain
* large sets of records with the same timestamp. We still maintain the last
* modified timestamp to allow for resuming a batch in the case of a conflicting
* write, detected via X-I-U-S.
*/
public class BookmarksMirrorer {
private let downloader: BatchingDownloader<BookmarkBasePayload>
private let storage: BookmarkBufferStorage
private let batchSize: Int
private let statsSession: SyncEngineStatsSession
public init(storage: BookmarkBufferStorage, client: Sync15CollectionClient<BookmarkBasePayload>, basePrefs: Prefs, collection: String, statsSession: SyncEngineStatsSession, batchSize: Int=100) {
self.storage = storage
self.downloader = BatchingDownloader(collectionClient: client, basePrefs: basePrefs, collection: collection)
self.batchSize = batchSize
self.statsSession = statsSession
}
var lastModified: Timestamp {
get {
return self.downloader.lastModified
}
}
// TODO
public func storageFormatDidChange() {
}
// TODO
public func onWipeWasAppliedToStorage() {
}
private func applyRecordsFromBatcher() -> Success {
let retrieved = self.downloader.retrieve()
let invalid = retrieved.filter { !$0.payload.isValid() }
if !invalid.isEmpty {
// There's nothing we can do with invalid input. There's also no point in
// tracking failing GUIDs here yet: if another client reuploads those records
// correctly, we'll encounter them routinely due to a newer timestamp.
// The only improvement we could make is to drop records from the buffer if we
// happen to see a new, invalid one before somehow syncing again, but that's
// unlikely enough that it's not worth doing.
//
// Bug 1258801 tracks recording telemetry for these invalid items, which is
// why we don't simply drop them on the ground at the download stage.
//
// We might also choose to perform certain simple recovery actions here: for example,
// bookmarks with null URIs are clearly invalid, and could be treated as if they
// weren't present on the server, or transparently deleted.
log.warning("Invalid records: \(invalid.map { $0.id }.joined(separator: ", ")).")
}
let mirrorItems = retrieved.flatMap { record -> BookmarkMirrorItem? in
guard record.payload.isValid() else {
return nil
}
return (record.payload as MirrorItemable).toMirrorItem(record.modified)
}
if mirrorItems.isEmpty {
log.debug("Got empty batch.")
return succeed()
}
log.debug("Applying \(mirrorItems.count) downloaded bookmarks.")
return self.storage.applyRecords(mirrorItems)
}
public func go(info: InfoCollections, greenLight: @escaping () -> Bool) -> SyncResult {
if !greenLight() {
log.info("Green light turned red. Stopping mirror operation.")
return deferMaybe(SyncStatus.notStarted(.redLight))
}
log.debug("Downloading up to \(self.batchSize) records.")
return self.downloader.go(info, limit: self.batchSize)
.bind { result in
guard let end = result.successValue else {
log.warning("Got failure: \(result.failureValue!)")
return deferMaybe(result.failureValue!)
}
switch end {
case .complete:
log.info("Done with batched mirroring.")
return self.applyRecordsFromBatcher()
>>> effect(self.downloader.advance)
>>> self.storage.doneApplyingRecordsAfterDownload
>>> always(SyncStatus.completed(self.statsSession.end()))
case .incomplete:
log.debug("Running another batch.")
// This recursion is fine because Deferred always pushes callbacks onto a queue.
return self.applyRecordsFromBatcher()
>>> effect(self.downloader.advance)
>>> { self.go(info: info, greenLight: greenLight) }
case .interrupted:
log.info("Interrupted. Aborting batching this time.")
return deferMaybe(SyncStatus.partial(self.statsSession))
case .noNewData:
log.info("No new data. No need to continue batching.")
self.downloader.advance()
return deferMaybe(SyncStatus.completed(self.statsSession.end()))
}
}
}
func advanceNextDownloadTimestampTo(timestamp: Timestamp) {
self.downloader.advanceTimestampTo(timestamp)
}
}

View file

@ -0,0 +1,571 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
import Storage
import Deferred
import SwiftyJSON
import SyncTelemetry
private let log = Logger.syncLogger
// How long should we wait after sending a repair request before we give up?
private let ResponseIntervalTimeout = OneDayInMilliseconds * 3
// The maximum number of IDs we will request to be repaired. Beyond this
// number we assume that trying to repair may do more harm than good and may
// ask another client to wipe the server and reupload everything. Bug 1341972
// is tracking that work for Desktop.
private let MaxRequestedIDs = 1000
// If a repair is in progress, this is the generated GUID for the "flow ID".
private let PrefFlowID = "flowID"
// The IDs we are currently trying to obtain via the repair process.
private let PrefMissingIDs = "ids"
// The ID of the client we're currently trying to get the missing items from.
private let PrefCurrentClient = "currentClient"
// The IDs of the clients we've previously tried to get the missing items
// from.
private let PrefPreviousClients = "previousClients"
// The time, in seconds, when we initiated the most recent client request.
private let PrefLastRepair = "when"
// Our current state.
private let PrefCurrentState = "state"
private enum RepairState: String {
// We have not started the repair process.
case notRepairing = ""
// We need to try to find another client to use.
case needNewClient = "repair.need-new-client"
// We've sent the first request to a client.
case sentRequest = "repair.sent"
// We've retried a request to a client.
case sentSecondRequest = "repair.sent-again"
// There were no problems, but we've gone as far as we can.
case finished = "repair.finished"
// We've found an error that forces us to abort this entire repair cycle.
case aborted = "repair.aborted"
}
struct RepairResponse {
let collection: String
let request: String
let flowID: String
let clientID: String
let ids: [String]
static func fromJSON(args: JSON) -> RepairResponse {
return RepairResponse(collection: args["collection"].stringValue, request: args["request"].stringValue,
flowID: args["flowID"].stringValue, clientID: args["clientID"].stringValue,
ids: args["ids"].arrayValue.map { $0.stringValue })
}
}
struct RepairRequest {
let collection: String
let request: String
let flowID: String
let requestor: String
let ids: [String]
func toSyncCommand() -> SyncCommand {
let jsonObj: [String: Any] = [
"command": "repairRequest",
"args": [
[
"collection": collection,
"request": request,
"flowID": flowID,
"requestor": requestor,
"ids": ids
]
]
]
return SyncCommand(value: JSON(object: jsonObj).stringValue()!)
}
}
private class AbortRepairError: MaybeErrorType {
let description: String
init(_ description: String) {
self.description = description
}
}
private class UnknownClientError: MaybeErrorType {
let description: String
init(_ description: String) {
self.description = description
}
}
private class InvalidStateError: MaybeErrorType {
let description: String
init(_ description: String) {
self.description = description
}
}
class BookmarksRepairRequestor {
let prefs: Prefs
let basePrefs: Prefs
let remoteClients: RemoteClientsAndTabs
let scratchpad: Scratchpad
init(scratchpad: Scratchpad, basePrefs: Prefs, remoteClients: RemoteClientsAndTabs) {
self.scratchpad = scratchpad
self.basePrefs = basePrefs
self.prefs = basePrefs.branch("repairs.bookmark")
self.remoteClients = remoteClients
}
/**
* See if the repairer is willing and able to begin a repair process given
* the specified validation information.
*
* - returns: true if a repair was started and false otherwise.
*/
func startRepairs(validationInfo: [BufferInconsistency: [GUID]], flowID: String = Bytes.generateGUID()) -> Deferred<Maybe<Bool>> {
guard self.currentState == .notRepairing else {
log.info("Can't start a repair - repair with ID \(self.flowID) is already in progress")
return deferMaybe(false)
}
let ids = self.getProblemIDs(validationInfo)
guard ids.count > 0 else {
log.info("Not starting a repair as there are no problems")
return deferMaybe(false)
}
guard ids.count <= MaxRequestedIDs else {
log.info("Not starting a repair as there are over \(MaxRequestedIDs) problems")
let extra = [
"flowID": flowID,
"reason": "too many problems: \(ids.count)"
]
let event = Event(category: "sync", method: "repair", object: "aborted", extra: extra)
recordTelemetry(event: event)
return deferMaybe(false)
}
return self.anyClientsRepairing() >>== { clientsRepairing in
guard !clientsRepairing else {
log.info("Can't start repair, since other clients are already repairing bookmarks")
let extra = [
"flowID": flowID,
"reason": "other clients repairing"
]
let event = Event(category: "sync", method: "repair", object: "aborted", extra: extra)
self.recordTelemetry(event: event)
return deferMaybe(false)
}
log.info("Starting a repair, looking for \(ids.count) missing item(s)")
// Setup our prefs to indicate we are on our way.
self.flowID = flowID
self.currentIDs = ids
self.currentState = .needNewClient
let extra = ["flowID": flowID, "numIDs": String(ids.count)]
let event = Event(category: "sync", method: "repair", object: "started", extra: extra)
self.recordTelemetry(event: event)
return self.continueRepairs()
}
}
/**
* Work out what state our current repair request is in, and whether it can
* proceed to a new state.
*
* - returns: true if we could continue the repair - even if the state didn't
* actually move. Returns false if we aren't actually repairing.
*/
func continueRepairs(response: RepairResponse? = nil) -> Deferred<Maybe<Bool>> {
// Note that "aborted" and "finished" should never be current when this
// function returns - this function resets to notRepairing in those cases.
guard self.currentState != .notRepairing else {
return deferMaybe(false)
}
var abortReason: String?
func runStateMachine(iteration: Int = 0) -> Deferred<Maybe<(state: RepairState, newState: RepairState)>> {
let state = self.currentState
log.info("continueRepairs starting with state \(state)")
return self.advanceRepairState(state: state, response: response).bind { result in
let newState: RepairState
if result.isSuccess {
newState = result.successValue!
log.info("continueRepairs has next state \(newState)")
} else {
let failure = result.failureValue!
if failure is AbortRepairError {
let reason = failure.description
log.info("Repair has been aborted: \(reason)")
newState = .aborted
abortReason = reason
} else {
return deferMaybe(failure)
}
}
let done = deferMaybe((state: state, newState: newState))
if newState == .aborted {
return done
}
self.currentState = newState
if state == newState {
return done
}
// we loop until the state doesn't change - but enforce a max of 10 times
// to prevent errors causing infinite loops.
return (iteration < 10) ? runStateMachine(iteration: iteration + 1) : done
}
}
return runStateMachine() >>== { stateMachineResult in
let state = stateMachineResult.state
let newState = stateMachineResult.newState
if state != newState {
log.error("continueRepairs spun without getting a new state")
}
if newState == .finished || newState == .aborted {
let method = newState == .finished ? "finished" : "aborted"
var extra = [
"flowID": self.flowID,
"numIDs": String(self.currentIDs.count),
]
if abortReason != nil {
extra["reason"] = abortReason
}
let event = Event(category: "sync", method: "repair", object: method, extra: extra)
self.recordTelemetry(event: event)
self.prefs.clearAll()
}
return deferMaybe(true)
}
}
func recordTelemetry(event: Event) {
var events = self.basePrefs.arrayForKey(PrefKeySyncEvents) as? [Data] ?? []
if let data = event.pickle(), event.validate() {
events.append(data)
self.basePrefs.setObject(events, forKey: PrefKeySyncEvents)
} else {
log.info("Event not recorded due to validation failure or pickling error!")
}
}
private func advanceRepairState(state: RepairState, response: RepairResponse?) -> Deferred<Maybe<RepairState>> {
return self.anyClientsRepairing(flowID: self.flowID) >>== { anyClientsRepairing in
guard !anyClientsRepairing else {
return deferMaybe(AbortRepairError("other clients repairing"))
}
switch state {
case .sentRequest, .sentSecondRequest:
let flowID = self.flowID
guard let clientID = self.currentRemoteClient else {
return deferMaybe(InvalidStateError("currentRemoteClient should be defined"))
}
if let response = response {
// We got an explicit response - let's see how we went.
return deferMaybe(self.handleResponse(state: state, response: response))
}
// So we've sent a request - and don't yet have a response. See if the
// client we sent it to has removed it from its list (ie, whether it
// has synced since we wrote the request.)
return self.remoteClients.getClientWithId(clientID) >>== { client in
guard let client = client else {
// hrmph - the client has disappeared.
log.info("previously requested client \(clientID) has vanished - moving to next step")
let extra = [
"deviceID": self.scratchpad.hashedDeviceID ?? "unknown_deviceID",
"flowID": flowID
]
let event = Event(category: "sync", method: "repair", object: "abandon", value: "missing", extra: extra)
self.recordTelemetry(event: event)
return deferMaybe(.needNewClient)
}
return self.isCommandPending(clientID: clientID, flowID: flowID) >>== { isCommandPending in
if isCommandPending {
// So the command we previously sent is still queued for the client
// (ie, that client is yet to have synced). Let's see if we should
// give up on that client.
if self.lastRepair + ResponseIntervalTimeout <= Date.now() {
log.info("previous request to client \(clientID) is pending, but has taken too long")
// XXX - should we remove the command?
let extra = [
"deviceID": self.scratchpad.hashedDeviceID ?? "unknown_deviceID",
"flowID": flowID
]
let event = Event(category: "sync", method: "repair", object: "abandon", value: "silent", extra: extra)
self.recordTelemetry(event: event)
return deferMaybe(.needNewClient)
}
// Let's continue to wait for that client to respond.
// We are now sure that timeLeft > 0, so we can calculate it (Timestamp type is UInt64)
let timeLeft = self.lastRepair + ResponseIntervalTimeout - Date.now()
log.verbose("previous request to client \(clientID) has \(timeLeft) seconds before we give up on it")
return deferMaybe(state)
}
// The command isn't pending - if this was the first request, we give
// it another go (as that client may have cleared the command but is yet
// to complete the sync)
// XXX - note that this is no longer true - the responders don't remove
// their command until they have written a response. This might mean
// we could drop the entire STATE.SENT_SECOND_REQUEST concept???
if state == .sentRequest {
log.info("previous request to client \(clientID) was removed - trying a second time")
return self.writeRequest(client: client) >>== { success in
return deferMaybe(.sentSecondRequest)
}
} else {
// this was the second time around, so give up on this client
log.info("previous 2 requests to client \(clientID) were removed - need a new client")
return deferMaybe(.needNewClient)
}
}
}
case .needNewClient:
// We need to find a new client to request.
return self.findNextClient() >>== { client in
guard let nextClient = client else {
return deferMaybe(.finished)
}
if let currentRemoteClient = self.currentRemoteClient {
var previousRemoteClients = self.previousRemoteClients
previousRemoteClients.append(currentRemoteClient)
self.previousRemoteClients = previousRemoteClients
}
self.currentRemoteClient = nextClient.guid!
return self.writeRequest(client: nextClient) >>== { success in
return deferMaybe(.sentRequest)
}
}
case .aborted:
break // our caller will take the abort action.
case .finished:
break
case .notRepairing:
// No repair is in progress. This is a common case, so only log trace.
log.verbose("continue repairs called but no repair in progress.")
break
}
return deferMaybe(state)
}
}
/**
* Handle being in the SENT_REQUEST or SENT_SECOND_REQUEST state with an
* explicit response.
*/
private func handleResponse(state: RepairState, response: RepairResponse) -> RepairState {
guard let clientID = self.currentRemoteClient else {
log.error("Cannot handle the response of an unknown client")
return state
}
let flowID = self.flowID
guard response.flowID == flowID && response.clientID == clientID &&
response.request == "upload" else {
log.info("got a response to a different repair request: \(response)")
// hopefully just a stale request that finally came in (either from
// an entirely different repair flow, or from a client we've since
// given up on.) It doesn't mean we need to abort though...
return state
}
// Pull apart the response and see if it provided everything we asked for.
let remainingIDs = Array(Set(self.currentIDs).subtracting(Set(response.ids)))
log.info("repair response from \(clientID) provided '\(response.ids)', remaining now '\(remainingIDs)'")
self.currentIDs = remainingIDs
let newState: RepairState
if remainingIDs.count > 0 {
// try a new client for the remaining ones.
newState = .needNewClient
} else {
newState = .finished
}
// record telemetry about this
let extra = [
"deviceID": scratchpad.hashedDeviceID ?? "unknown_deviceID",
"flowID": flowID,
"numIDs": String(response.ids.count)
]
let event = Event(category: "sync", method: "repair", object: "response", value: "upload", extra: extra)
recordTelemetry(event: event)
return newState
}
/**
* Issue a repair request to a specific client.
*/
private func writeRequest(client: RemoteClient) -> Success {
log.verbose("writing repair request to client \(client.guid!)")
let ids = self.currentIDs
let flowID = self.flowID
// Post a command to that client.
let request = RepairRequest(collection: "bookmarks", request: "upload", flowID: flowID, requestor: self.scratchpad.clientGUID, ids: ids)
return self.remoteClients.insertCommand(request.toSyncCommand(), forClients: [client]) >>== { (_: Int) -> Success in
self.lastRepair = Date.now()
// record telemetry about this
let extra = [
"deviceID": self.scratchpad.hashedDeviceID ?? "unknown_deviceID",
"flowID": flowID,
"numIDs": String(ids.count),
]
let event = Event(category: "sync", method: "repair", object: "request", value: "upload", extra: extra)
self.recordTelemetry(event: event)
return succeed()
}
}
private func findNextClient() -> Deferred<Maybe<RemoteClient?>> {
var alreadyDone = self.previousRemoteClients
if let currentRemoteClient = self.currentRemoteClient {
alreadyDone.append(currentRemoteClient)
}
return self.remoteClients.getClients() >>== { remoteClients in
// we want to consider the most-recently synced clients first.
let sortedClients = remoteClients.sorted(by: { (a, b) -> Bool in
return a.modified > b.modified
})
for client in sortedClients {
log.verbose("findNextClient considering \(client)")
guard let clientID = client.guid else {
continue
}
if !alreadyDone.contains(clientID) && self.isSuitableClient(client) {
return deferMaybe(client)
}
}
log.verbose("findNextClient found no client")
return deferMaybe(nil)
}
}
private func isSuitableClient(_ client: RemoteClient) -> Bool {
if let type = client.type,
let version = client.version,
let major = Int(version.components(separatedBy: ".")[0]) {
return type == "desktop" && major > 53
}
return false
}
private func getProblemIDs(_ validations: [BufferInconsistency: [GUID]]) -> [GUID] {
return validations.reduce([]) { acc, pair in acc + pair.value }
}
private func anyClientsRepairing(flowID: String? = nil) -> Deferred<Maybe<Bool>> {
return self.remoteClients.getCommands() >>== { allCommands in
return deferMaybe(allCommands.contains { (clientID: GUID, commands: [SyncCommand]) in
return commands.contains { (command: SyncCommand) in
let json = JSON(parseJSON: command.value)
guard let cmdName = json["command"].string,
let argsArray = json["args"].array,
let argObj = argsArray[0].dictionary,
let argCol = argObj["collection"]?.string,
let argFlowID = argObj["flowID"]?.string
else {
return false
}
return !((cmdName != "repairResponse" && cmdName != "repairRequest") ||
argsArray.count != 1 ||
argCol != "bookmarks" ||
argFlowID == flowID
)
}
})
}
}
private func isCommandPending(clientID: String, flowID: String) -> Deferred<Maybe<Bool>> {
return self.remoteClients.getCommands() >>== { allCommands in
guard let commands = allCommands[clientID] else {
return deferMaybe(false)
}
return deferMaybe(commands.contains { (command: SyncCommand) in
let json = JSON(parseJSON: command.value)
guard let cmdName = json["command"].string,
let argsArray = json["args"].array,
let argObj = argsArray[0].dictionary,
let argCol = argObj["collection"]?.string,
let argFlowID = argObj["flowID"]?.string,
let argRequest = argObj["request"]?.string
else {
return false
}
return cmdName == "repairRequest" && argsArray.count == 1 &&
argCol == "bookmarks" && argRequest == "upload" &&
argFlowID == flowID
})
}
}
private var flowID: String {
get { return self.prefs.stringForKey(PrefFlowID)! }
set { self.prefs.setString(newValue, forKey: PrefFlowID) }
}
private var currentIDs: [String] {
get { return self.prefs.stringArrayForKey(PrefMissingIDs) ?? [String]() }
set { self.prefs.setObject(newValue, forKey: PrefMissingIDs) }
}
private var currentRemoteClient: String? {
get { return self.prefs.stringForKey(PrefCurrentClient) }
set { self.prefs.setString(newValue!, forKey: PrefCurrentClient) }
}
private var previousRemoteClients: [String] {
get { return self.prefs.stringArrayForKey(PrefPreviousClients) ?? [String]() }
set { self.prefs.setObject(newValue, forKey: PrefPreviousClients) }
}
private var lastRepair: Timestamp {
get { return self.prefs.timestampForKey(PrefLastRepair)! }
set { self.prefs.setTimestamp(newValue, forKey: PrefLastRepair) }
}
private var currentState: RepairState {
get {
let Default = RepairState.notRepairing
guard let raw = self.prefs.stringForKey(PrefCurrentState) else {
return Default
}
return RepairState(rawValue: raw) ?? Default
}
set { self.prefs.setString(newValue.rawValue, forKey: PrefCurrentState) }
}
}

View file

@ -0,0 +1,511 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Deferred
import Foundation
import Shared
import Storage
import XCGLogger
private let log = Logger.syncLogger
typealias UploadFunction = ([Record<BookmarkBasePayload>], _ lastTimestamp: Timestamp?, _ onUpload: @escaping (POSTResult, Timestamp?) -> DeferredTimestamp) -> DeferredTimestamp
class TrivialBookmarkStorer: BookmarkStorer {
let uploader: UploadFunction
init(uploader: @escaping UploadFunction) {
self.uploader = uploader
}
func applyUpstreamCompletionOp(_ op: UpstreamCompletionOp, itemSources: ItemSources, trackingTimesInto local: LocalOverrideCompletionOp) -> Deferred<Maybe<POSTResult>> {
log.debug("Uploading \(op.records.count) modified records.")
log.debug("Uploading \(op.amendChildrenFromBuffer.count) amended buffer records.")
log.debug("Uploading \(op.amendChildrenFromMirror.count) amended mirror records.")
log.debug("Uploading \(op.amendChildrenFromLocal.count) amended local records.")
var records: [Record<BookmarkBasePayload>] = []
records.reserveCapacity(op.records.count + op.amendChildrenFromBuffer.count + op.amendChildrenFromLocal.count + op.amendChildrenFromMirror.count)
records.append(contentsOf: op.records)
func accumulateFromAmendMap(_ itemsWithNewChildren: [GUID: [GUID]], fetch: ([GUID: [GUID]]) -> Maybe<[GUID: BookmarkMirrorItem]>) throws /* MaybeErrorType */ {
if itemsWithNewChildren.isEmpty {
return
}
let fetched = fetch(itemsWithNewChildren)
guard let items = fetched.successValue else {
log.warning("Couldn't fetch items to amend.")
throw fetched.failureValue!
}
items.forEach { (guid, item) in
let payload = item.asPayloadWithChildren(itemsWithNewChildren[guid])
let mappedGUID = payload["id"].string ?? guid
let record = Record<BookmarkBasePayload>(id: mappedGUID, payload: payload)
records.append(record)
}
}
do {
try accumulateFromAmendMap(op.amendChildrenFromBuffer, fetch: { itemSources.buffer.getBufferItemsWithGUIDs($0.keys).value })
try accumulateFromAmendMap(op.amendChildrenFromMirror, fetch: { itemSources.mirror.getMirrorItemsWithGUIDs($0.keys).value })
try accumulateFromAmendMap(op.amendChildrenFromLocal, fetch: { itemSources.local.getLocalItemsWithGUIDs($0.keys).value })
} catch {
return deferMaybe(error as MaybeErrorType)
}
var success: [GUID] = []
var failed: [GUID: String] = [:]
func onUpload(_ result: POSTResult, lastModified: Timestamp?) -> DeferredTimestamp {
success.append(contentsOf: result.success)
result.failed.forEach { guid, message in
failed[guid] = message
}
log.debug("Uploaded records got timestamp \(lastModified ??? "nil").")
let modified = lastModified ?? 0
local.setModifiedTime(modified, guids: result.success)
return deferMaybe(modified)
}
// Chain the last upload timestamp right into our lastFetched timestamp.
// This is what Sync clients tend to do, but we can probably do better.
return uploader(records, op.ifUnmodifiedSince, onUpload)
// As if we uploaded everything in one go.
>>> { deferMaybe(POSTResult(success: success, failed: failed)) }
}
}
open class MalformedRecordError: MaybeErrorType, SyncPingFailureFormattable {
open var description: String {
return "Malformed record."
}
open var failureReasonName: SyncPingFailureReasonName {
return .otherError
}
}
// MARK: - External synchronizer interface.
open class BufferingBookmarksSynchronizer: TimestampedSingleCollectionSynchronizer, Synchronizer {
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "bookmarks")
}
override var storageVersion: Int {
return BookmarksStorageVersion
}
fileprivate func buildMobileRootRecord(_ local: LocalItemSource, _ buffer: BufferItemSource, additionalChildren: [BookmarkMirrorItem], deletedChildren: [GUID]) -> Deferred<Maybe<Record<BookmarkBasePayload>>> {
let newBookmarkGUIDs = additionalChildren.map { $0.guid }
return buffer.getBufferItemWithGUID(BookmarkRoots.MobileFolderGUID).bind { maybeMobileRoot in
// Update (or create!) the Mobile Root folder with its new children.
if let mobileRoot = maybeMobileRoot.successValue {
return buffer.getBufferChildrenGUIDsForParent(mobileRoot.guid)
.map { $0.map({ (mobileRoot: mobileRoot, children: $0.filter { !deletedChildren.contains($0) } + newBookmarkGUIDs) }) }
} else {
return local.getLocalItemWithGUID(BookmarkRoots.MobileFolderGUID)
.map { $0.map({ (mobileRoot: $0, children: newBookmarkGUIDs) }) }
}
} >>== { (mobileRoot: BookmarkMirrorItem, children: [GUID]) in
let payload = mobileRoot.asPayloadWithChildren(children)
guard let mappedGUID = payload["id"].string else {
return deferMaybe(MalformedRecordError())
}
return deferMaybe(Record<BookmarkBasePayload>(id: mappedGUID, payload: payload))
}
}
func buildMobileRootAndChildrenRecords(_ local: LocalItemSource, _ buffer: BufferItemSource, additionalChildren: [BookmarkMirrorItem], deletedChildren: [GUID]) -> Deferred<Maybe<(mobileRootRecord: Record<BookmarkBasePayload>, childrenRecords: [Record<BookmarkBasePayload>])>> {
let childrenRecords =
additionalChildren.map { bkm -> Record<BookmarkBasePayload> in
let payload = bkm.asPayload()
let mappedGUID = payload["id"].string ?? bkm.guid
return Record<BookmarkBasePayload>(id: mappedGUID, payload: payload)
} +
deletedChildren.map { guid -> Record<BookmarkBasePayload> in
let payload = BookmarkBasePayload.deletedPayload(guid)
let mappedGUID = payload["id"].string ?? guid
return Record<BookmarkBasePayload>(id: mappedGUID, payload: payload)
}
return self.buildMobileRootRecord(local, buffer, additionalChildren: additionalChildren, deletedChildren: deletedChildren) >>== { mobileRootRecord in
return deferMaybe((mobileRootRecord: mobileRootRecord, childrenRecords: childrenRecords))
}
}
func uploadSomeLocalRecords(_ storage: SyncableBookmarks & LocalItemSource & MirrorItemSource, _ mirrorer: BookmarksMirrorer, _ bookmarksClient: Sync15CollectionClient<BookmarkBasePayload>, mobileRootRecord: Record<BookmarkBasePayload>, childrenRecords: [Record<BookmarkBasePayload>]) -> Success {
var newBookmarkGUIDs: [GUID] = []
var deletedBookmarksGUIDs: [GUID] = []
for record in childrenRecords {
// No mutable l-values in Swift :(
if record.payload.deleted {
deletedBookmarksGUIDs.append(record.id)
} else {
newBookmarkGUIDs.append(record.id)
}
}
let records = [mobileRootRecord] + childrenRecords
return self.uploadRecordsSingleBatch(records, lastTimestamp: mirrorer.lastModified, storageClient: bookmarksClient) >>== { (timestamp: Timestamp, succeeded: [GUID]) -> Success in
let bufferValuesToMoveFromLocal = Set(newBookmarkGUIDs).intersection(Set(succeeded))
let deletedValues = Set(deletedBookmarksGUIDs).intersection(Set(succeeded))
let mobileRoot = (mobileRootRecord.payload as MirrorItemable).toMirrorItem(timestamp)
let bufferOP = BufferUpdatedCompletionOp(bufferValuesToMoveFromLocal: bufferValuesToMoveFromLocal, deletedValues: deletedValues, mobileRoot: mobileRoot, modifiedTime: timestamp)
return storage.applyBufferUpdatedCompletionOp(bufferOP) >>> {
mirrorer.advanceNextDownloadTimestampTo(timestamp: timestamp) // We need to advance our batching downloader timestamp to match. See Bug 1253458.
return succeed()
}
}
}
open func synchronizeBookmarksToStorage(_ storage: SyncableBookmarks & LocalItemSource & MirrorItemSource, usingBuffer buffer: BookmarkBufferStorage & BufferItemSource, withServer storageClient: Sync15StorageClient, info: InfoCollections, greenLight: @escaping () -> Bool, remoteClientsAndTabs: RemoteClientsAndTabs) -> SyncResult {
if self.prefs.boolForKey("dateAddedMigrationDone") != true {
self.lastFetched = 0
self.prefs.setBool(true, forKey: "dateAddedMigrationDone")
}
if let reason = self.reasonToNotSync(storageClient) {
return deferMaybe(.notStarted(reason))
}
let encoder = RecordEncoder<BookmarkBasePayload>(decode: BookmarkType.somePayloadFromJSON, encode: { $0.json })
guard let bookmarksClient = self.collectionClient(encoder, storageClient: storageClient) else {
log.error("Couldn't make bookmarks factory.")
return deferMaybe(FatalError(message: "Couldn't make bookmarks factory."))
}
let start = Date.nowMicroseconds()
let mirrorer = BookmarksMirrorer(storage: buffer, client: bookmarksClient, basePrefs: self.prefs, collection: "bookmarks", statsSession: self.statsSession)
let storer = TrivialBookmarkStorer(uploader: { records, lastTimestamp, onUpload in
let timestamp = lastTimestamp ?? self.lastFetched
return self.uploadRecords(records, lastTimestamp: timestamp, storageClient: bookmarksClient, onUpload: onUpload)
>>== effect { timestamp in
// We need to advance our batching downloader timestamp to match. See Bug 1253458.
self.setTimestamp(timestamp)
mirrorer.advanceNextDownloadTimestampTo(timestamp: timestamp)
}
})
statsSession.start()
let doMirror = mirrorer.go(info: info, greenLight: greenLight)
let run: SyncResult
if !AppConstants.shouldMergeBookmarks {
run = doMirror >>== { result -> SyncResult in
// Validate the buffer to report statistics.
if case .completed = result {
log.debug("Validating completed buffer download.")
return buffer.validate().bind { validationResult in
guard let invalidError = validationResult.failureValue as? BufferInvalidError else {
return deferMaybe(result)
}
return buffer.getUpstreamRecordCount().bind { checked -> Success in
self.statsSession.validationStats = self.validationStatsFrom(error: invalidError, checked: checked)
return self.maybeStartRepairProcedure(greenLight: greenLight, error: invalidError, remoteClientsAndTabs: remoteClientsAndTabs)
} >>> {
return deferMaybe(result)
}
}
}
return deferMaybe(result)
} >>== { result in
guard AppConstants.MOZ_SIMPLE_BOOKMARKS_SYNCING else {
return deferMaybe(result)
}
guard case .completed = result else {
return deferMaybe(result)
}
// -1 because we also need to upload the mobile root.
return (storage.getLocalBookmarksModifications(limit: bookmarksClient.maxBatchPostRecords - 1) >>== { (deletedGUIDs, newBookmarks) -> Success in
guard newBookmarks.count > 0 || deletedGUIDs.count > 0 else {
return succeed()
}
return self.buildMobileRootAndChildrenRecords(storage, buffer, additionalChildren: newBookmarks, deletedChildren: deletedGUIDs) >>== { (mobileRootRecord, childrenRecords) in
return self.uploadSomeLocalRecords(storage, mirrorer, bookmarksClient, mobileRootRecord: mobileRootRecord, childrenRecords: childrenRecords)
}
}).bind { simpleSyncingResult in
if let failure = simpleSyncingResult.failureValue {
let description = failure is RecordTooLargeError ? "Record too large" : failure.description
Sentry.shared.send(message: "Failed to simple sync bookmarks", tag: SentryTag.bookmarks, severity: .error, description: description)
}
return deferMaybe(result)
}
}
} else {
run = doMirror >>== { result in
// Only bother trying to sync if the mirror operation wasn't interrupted or partial.
if case .completed = result {
return buffer.validate().bind { result in
if let invalidError = result.failureValue as? BufferInvalidError {
return buffer.getUpstreamRecordCount().bind { checked in
self.statsSession.validationStats = self.validationStatsFrom(error: invalidError, checked: checked)
return self.maybeStartRepairProcedure(greenLight: greenLight, error: invalidError, remoteClientsAndTabs: remoteClientsAndTabs) >>> {
return deferMaybe(invalidError)
}
}
}
let applier = MergeApplier(buffer: buffer, storage: storage, client: storer, statsSession: self.statsSession, greenLight: greenLight)
return applier.go()
}
}
return deferMaybe(result)
}
}
run.upon { result in
let end = Date.nowMicroseconds()
let duration = end - start
log.info("Bookmark \(AppConstants.shouldMergeBookmarks ? "sync" : "mirroring") took \(duration)µs. Result was \(result.successValue?.description ?? result.failureValue?.description ?? "failure")")
}
return run
}
private func validationStatsFrom(error: BufferInvalidError, checked: Int?) -> ValidationStats {
let problems = error.inconsistencies.map { ValidationProblem(name: $0.trackingEvent, count: $1.count) }
return ValidationStats(problems: problems, took: error.validationDuration, checked: checked)
}
private func maybeStartRepairProcedure(greenLight: () -> Bool, error: BufferInvalidError, remoteClientsAndTabs: RemoteClientsAndTabs) -> Success {
guard AppConstants.MOZ_BOOKMARKS_REPAIR_REQUEST && greenLight() else {
return succeed()
}
log.warning("Buffer inconsistent, starting repair procedure")
let repairer = BookmarksRepairRequestor(scratchpad: self.scratchpad, basePrefs: self.basePrefs, remoteClients: remoteClientsAndTabs)
return repairer.startRepairs(validationInfo: error.inconsistencies).bind { result in
if let repairFailure = result.failureValue {
Sentry.shared.send(message: "Bookmarks repair failure", tag: SentryTag.bookmarks, severity: .error, description: repairFailure.description)
} else {
Sentry.shared.send(message: "Bookmarks repair succeeded", tag: SentryTag.bookmarks, severity: .debug)
}
return succeed()
}
}
}
class MergeApplier {
let greenLight: () -> Bool
let buffer: BookmarkBufferStorage
let storage: SyncableBookmarks
let client: BookmarkStorer
let merger: BookmarksStorageMerger
let statsSession: SyncEngineStatsSession
init(buffer: BookmarkBufferStorage & BufferItemSource, storage: SyncableBookmarks & LocalItemSource & MirrorItemSource, client: BookmarkStorer, statsSession: SyncEngineStatsSession, greenLight: @escaping () -> Bool) {
self.greenLight = greenLight
self.buffer = buffer
self.storage = storage
self.merger = ThreeWayBookmarksStorageMerger(buffer: buffer, storage: storage)
self.client = client
self.statsSession = statsSession
}
// Exposed for use from tests.
func applyResult(_ result: BookmarksMergeResult) -> Success {
return result.applyToClient(self.client, storage: self.storage, buffer: self.buffer)
}
func go() -> SyncResult {
guard self.greenLight() else {
log.info("Green light turned red; not merging bookmarks.")
return deferMaybe(SyncStatus.completed(statsSession.end()))
}
return self.merger.merge()
>>== self.applyResult
>>> always(SyncStatus.completed(statsSession.end()))
}
}
/**
* The merger takes as input an existing storage state (mirror and local override),
* a buffer of new incoming records that relate to the mirror, and performs a three-way
* merge.
*
* The merge itself does not mutate storage. The result of the merge is conceptually a
* tuple: a new mirror state, a set of reconciled + locally changed records to upload,
* and two checklists of buffer and local state to discard.
*
* Typically the merge will be complete, resulting in a new mirror state, records to
* upload, and completely emptied buffer and local. In the case of partial inconsistency
* this will not be the case; incomplete subtrees will remain in the buffer. (We don't
* expect local subtrees to ever be incomplete.)
*
* It is expected that the caller will immediately apply the result in this order:
*
* 1. Upload the remote changes, if any. If this fails we can retry the entire process.
*
* 2(a). Apply the local changes, if any. If this fails we will re-download the records
* we just uploaded, and should reach the same end state.
* This step takes a timestamp key from (1), because pushing a record into the mirror
* requires a server timestamp.
*
* 2(b). Switch to the new mirror state. If this fails, we should find that our reconciled
* server contents apply neatly to our mirror and empty local, and we'll reach the
* same end state.
*
* Mirror state is applied in a sane order to respect relational constraints, even though
* we configure sqlite to defer constraint validation until the transaction is committed.
* That means:
*
* - Add any new records in the value table.
* - Change any existing records in the value table.
* - Update structure.
* - Remove records from the value table.
*
* 3. Apply buffer changes. We only do this after the mirror has advanced; if we fail to
* clean up the buffer, it'll reconcile neatly with the mirror on a subsequent try.
*
* 4. Update bookkeeping timestamps. If this fails we will download uploaded records,
* find they match, and have no repeat merging work to do.
*
* The goal of merging is that the buffer is empty (because we reconciled conflicts and
* updated the server), our local overlay is empty (because we reconciled conflicts and
* applied our changes to the server), and the mirror matches the server.
*
* Note that upstream application is robust: we can use XIUS to ensure that writes don't
* race. Buffer application is similarly robust, because this code owns all writes to the
* buffer. Local and mirror application, however, is not: the user's actions can cause
* changes to write to the database before we're done applying the results of a sync.
* We mitigate this a little by being explicit about the local changes that we're flushing
* (rather than, say, `DELETE FROM local`), but to do better we'd need change detection
* (e.g., an in-memory monotonic counter) or locking to prevent bookmark operations from
* racing. Later!
*/
protocol BookmarksStorageMerger: class {
init(buffer: BookmarkBufferStorage & BufferItemSource, storage: SyncableBookmarks & LocalItemSource & MirrorItemSource)
func merge() -> Deferred<Maybe<BookmarksMergeResult>>
}
class NoOpBookmarksMerger: BookmarksStorageMerger {
let buffer: BookmarkBufferStorage & BufferItemSource
let storage: SyncableBookmarks & LocalItemSource & MirrorItemSource
required init(buffer: BookmarkBufferStorage & BufferItemSource, storage: SyncableBookmarks & LocalItemSource & MirrorItemSource) {
self.buffer = buffer
self.storage = storage
}
func merge() -> Deferred<Maybe<BookmarksMergeResult>> {
return deferMaybe(BookmarksMergeResult.NoOp(ItemSources(local: self.storage, mirror: self.storage, buffer: self.buffer)))
}
}
class ThreeWayBookmarksStorageMerger: BookmarksStorageMerger {
let buffer: BookmarkBufferStorage & BufferItemSource
let storage: SyncableBookmarks & LocalItemSource & MirrorItemSource
required init(buffer: BookmarkBufferStorage & BufferItemSource, storage: SyncableBookmarks & LocalItemSource & MirrorItemSource) {
self.buffer = buffer
self.storage = storage
}
// MARK: - BookmarksStorageMerger.
// Trivial one-way sync.
fileprivate func applyLocalDirectlyToMirror() -> Deferred<Maybe<BookmarksMergeResult>> {
// Theoretically, we do the following:
// * Construct a virtual bookmark tree overlaying local on the mirror.
// * Walk the tree to produce Sync records.
// * Upload those records.
// * Flatten that tree into the mirror, clearing local.
//
// This is simpler than a full three-way merge: it's tree delta then flatten.
//
// But we are confident that our local changes, when overlaid on the mirror, are
// consistent. So we can take a little bit of a shortcut: process records
// directly, rather than building a tree.
//
// So do the following:
// * Take everything in `local` and turn it into a Sync record. This means pulling
// folder hierarchies out of localStructure, values out of local, and turning
// them into records. Do so in hierarchical order if we can, and set sortindex
// attributes to put folders first.
// * Upload those records in as few batches as possible. Ensure that each batch
// is consistent, if at all possible, though we're hoping for server support for
// atomic writes.
// * Take everything in local that was successfully uploaded and move it into the
// mirror, using the timestamps we tracked from the upload.
//
// Optionally, set 'again' to true in our response, and do this work only for a
// particular subtree (e.g., a single root, or a single branch of changes). This
// allows us to make incremental progress.
// TODO
log.debug("No special-case local-only merging yet. Falling back to three-way merge.")
return self.threeWayMerge()
}
fileprivate func applyIncomingDirectlyToMirror() -> Deferred<Maybe<BookmarksMergeResult>> {
// If the incoming buffer is consistent -- and the result of the mirrorer
// gives us a hint about that! -- then we can move the buffer records into
// the mirror directly.
//
// Note that this is also true for entire subtrees: if none of the children
// of, say, 'menu________' are modified locally, then we can apply it without
// merging.
//
// TODO
log.debug("No special-case remote-only merging yet. Falling back to three-way merge.")
return self.threeWayMerge()
}
// This is exposed for testing.
func getMerger() -> Deferred<Maybe<ThreeWayTreeMerger>> {
return self.storage.treesForEdges() >>== { (local, remote) in
// At this point *might* have two empty trees. This should only be the case if
// there are value-only changes (e.g., a renamed bookmark).
// We don't fail in that case, but we could optimize here.
// Find the mirror tree so we can compare.
return self.storage.treeForMirror() >>== { mirror in
// At this point we know that there have been changes both locally and remotely.
// (Or, in the general case, changes either locally or remotely.)
let itemSources = ItemSources(local: CachingLocalItemSource(source: self.storage), mirror: CachingMirrorItemSource(source: self.storage), buffer: CachingBufferItemSource(source: self.buffer))
return deferMaybe(ThreeWayTreeMerger(local: local, mirror: mirror, remote: remote, itemSources: itemSources))
}
}
}
func getMergedTree() -> Deferred<Maybe<MergedTree>> {
return self.getMerger() >>== { $0.produceMergedTree() }
}
func threeWayMerge() -> Deferred<Maybe<BookmarksMergeResult>> {
return self.getMerger() >>== { $0.produceMergedTree() >>== $0.produceMergeResultFromMergedTree }
}
func merge() -> Deferred<Maybe<BookmarksMergeResult>> {
return self.buffer.isEmpty() >>== { noIncoming in
// TODO: the presence of empty desktop roots in local storage
// isn't something we really need to worry about. Can we skip it here?
return self.storage.isUnchanged() >>== { noOutgoing in
switch (noIncoming, noOutgoing) {
case (true, true):
// Nothing to do!
log.debug("No incoming and no outgoing records: no-op.")
return deferMaybe(BookmarksMergeResult.NoOp(ItemSources(local: self.storage, mirror: self.storage, buffer: self.buffer)))
case (true, false):
// No incoming records to apply. Unilaterally apply local changes.
return self.applyLocalDirectlyToMirror()
case (false, true):
// No outgoing changes. Unilaterally apply remote changes if they're consistent.
return self.buffer.validate() >>> self.applyIncomingDirectlyToMirror
default:
// Changes on both sides. Merge.
return self.buffer.validate() >>> self.threeWayMerge
}
}
}
}
}

View file

@ -0,0 +1,336 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Deferred
import Foundation
import Shared
import Storage
import XCGLogger
private let log = Logger.syncLogger
// Because generic protocols in Swift are a pain in the ass.
public protocol BookmarkStorer: class {
// TODO: this should probably return a timestamp.
func applyUpstreamCompletionOp(_ op: UpstreamCompletionOp, itemSources: ItemSources, trackingTimesInto local: LocalOverrideCompletionOp) -> Deferred<Maybe<POSTResult>>
}
open class UpstreamCompletionOp: PerhapsNoOp {
// Upload these records from the buffer, but with these child lists.
open var amendChildrenFromBuffer: [GUID: [GUID]] = [:]
// Upload these records from the mirror, but with these child lists.
open var amendChildrenFromMirror: [GUID: [GUID]] = [:]
// Upload these records from local, but with these child lists.
open var amendChildrenFromLocal: [GUID: [GUID]] = [:]
// Upload these records as-is.
open var records: [Record<BookmarkBasePayload>] = []
open let ifUnmodifiedSince: Timestamp?
open var isNoOp: Bool {
return records.isEmpty
}
public init(ifUnmodifiedSince: Timestamp?=nil) {
self.ifUnmodifiedSince = ifUnmodifiedSince
}
}
open class BookmarksMergeResult: PerhapsNoOp {
let uploadCompletion: UpstreamCompletionOp
let overrideCompletion: LocalOverrideCompletionOp
let bufferCompletion: BufferCompletionOp
let itemSources: ItemSources
open var isNoOp: Bool {
return self.uploadCompletion.isNoOp &&
self.overrideCompletion.isNoOp &&
self.bufferCompletion.isNoOp
}
func applyToClient(_ client: BookmarkStorer, storage: SyncableBookmarks, buffer: BookmarkBufferStorage) -> Success {
return client.applyUpstreamCompletionOp(self.uploadCompletion, itemSources: self.itemSources, trackingTimesInto: self.overrideCompletion)
>>> { storage.applyLocalOverrideCompletionOp(self.overrideCompletion, itemSources: self.itemSources) }
>>> { buffer.applyBufferCompletionOp(self.bufferCompletion, itemSources: self.itemSources) }
}
init(uploadCompletion: UpstreamCompletionOp, overrideCompletion: LocalOverrideCompletionOp, bufferCompletion: BufferCompletionOp, itemSources: ItemSources) {
self.uploadCompletion = uploadCompletion
self.overrideCompletion = overrideCompletion
self.bufferCompletion = bufferCompletion
self.itemSources = itemSources
}
static func NoOp(_ itemSources: ItemSources) -> BookmarksMergeResult {
return BookmarksMergeResult(uploadCompletion: UpstreamCompletionOp(), overrideCompletion: LocalOverrideCompletionOp(), bufferCompletion: BufferCompletionOp(), itemSources: itemSources)
}
}
// MARK: - Errors.
open class BookmarksMergeError: MaybeErrorType, SyncPingFailureFormattable {
fileprivate let error: Error?
init(error: Error?=nil) {
self.error = error
}
open var description: String {
return "Merge error: \(self.error ??? "nil")"
}
open var failureReasonName: SyncPingFailureReasonName {
return .otherError
}
}
open class BookmarksMergeConsistencyError: BookmarksMergeError {
override open var description: String {
return "Merge consistency error"
}
}
open class BookmarksMergeErrorTreeIsUnrooted: BookmarksMergeConsistencyError {
open let roots: Set<GUID>
public init(roots: Set<GUID>) {
self.roots = roots
}
override open var description: String {
return "Tree is unrooted: roots are \(self.roots)"
}
}
enum MergeState<T> {
case unknown // Default state.
case unchanged // Nothing changed: no work needed.
case remote // Take the associated remote value.
case local // Take the associated local value.
case new(value: T) // Take this synthesized value.
var isUnchanged: Bool {
if case .unchanged = self {
return true
}
return false
}
var isUnknown: Bool {
if case .unknown = self {
return true
}
return false
}
var label: String {
switch self {
case .unknown:
return "Unknown"
case .unchanged:
return "Unchanged"
case .remote:
return "Remote"
case .local:
return "Local"
case .new:
return "New"
}
}
}
func ==<T: Equatable>(lhs: MergeState<T>, rhs: MergeState<T>) -> Bool {
switch (lhs, rhs) {
case (.unknown, .unknown):
return true
case (.unchanged, .unchanged):
return true
case (.remote, .remote):
return true
case (.local, .local):
return true
case let (.new(lh), .new(rh)):
return lh == rh
default:
return false
}
}
/**
* Using this:
*
* You get one for the root. Then you give it children for the roots
* from the mirror.
*
* Then you walk those, populating the remote and local nodes by looking
* at the left/right trees.
*
* By comparing left and right, and doing value-based comparisons if necessary,
* a merge state is decided and assigned for both value and structure.
*
* One then walks both left and right child structures (both to ensure that
* all nodes on both left and right will be visited!) recursively.
*/
class MergedTreeNode {
let guid: GUID
let mirror: BookmarkTreeNode?
var remote: BookmarkTreeNode?
var local: BookmarkTreeNode?
var hasLocal: Bool { return self.local != nil }
var hasMirror: Bool { return self.mirror != nil }
var hasRemote: Bool { return self.remote != nil }
var valueState: MergeState<BookmarkMirrorItem> = MergeState.unknown
var structureState: MergeState<BookmarkTreeNode> = MergeState.unknown
var hasDecidedChildren: Bool {
return !self.structureState.isUnknown
}
var mergedChildren: [MergedTreeNode]?
// One-sided constructors.
static func forRemote(_ remote: BookmarkTreeNode, mirror: BookmarkTreeNode?=nil) -> MergedTreeNode {
let n = MergedTreeNode(guid: remote.recordGUID, mirror: mirror, structureState: MergeState.remote)
n.remote = remote
n.valueState = MergeState.remote
return n
}
static func forLocal(_ local: BookmarkTreeNode, mirror: BookmarkTreeNode?=nil) -> MergedTreeNode {
let n = MergedTreeNode(guid: local.recordGUID, mirror: mirror, structureState: MergeState.local)
n.local = local
n.valueState = MergeState.local
return n
}
static func forUnchanged(_ mirror: BookmarkTreeNode) -> MergedTreeNode {
let n = MergedTreeNode(guid: mirror.recordGUID, mirror: mirror, structureState: MergeState.unchanged)
n.valueState = MergeState.unchanged
return n
}
init(guid: GUID, mirror: BookmarkTreeNode?, structureState: MergeState<BookmarkTreeNode>) {
self.guid = guid
self.mirror = mirror
self.structureState = structureState
}
init(guid: GUID, mirror: BookmarkTreeNode?) {
self.guid = guid
self.mirror = mirror
}
// N.B., you cannot recurse down `decidedStructure`: you'll depart from the
// merged tree. You need to use `mergedChildren` instead.
fileprivate var decidedStructure: BookmarkTreeNode? {
switch self.structureState {
case .unknown:
return nil
case .unchanged:
return self.mirror
case .remote:
return self.remote
case .local:
return self.local
case let .new(node):
return node
}
}
func asUnmergedTreeNode() -> BookmarkTreeNode {
return self.decidedStructure ?? BookmarkTreeNode.unknown(guid: self.guid)
}
// Recursive. Starts returning Unknown when nodes haven't been processed.
func asMergedTreeNode() -> BookmarkTreeNode {
guard let decided = self.decidedStructure,
let merged = self.mergedChildren else {
return BookmarkTreeNode.unknown(guid: self.guid)
}
if case .folder = decided {
let children = merged.map { $0.asMergedTreeNode() }
return BookmarkTreeNode.folder(guid: self.guid, children: children)
}
return decided
}
var isFolder: Bool {
return self.mergedChildren != nil
}
func dump(_ indent: Int) {
precondition(indent < 200)
let r: Character = "R"
let l: Character = "L"
let m: Character = "M"
let ind = indenting(indent)
print(ind, "[V: ", box(self.remote, r), box(self.mirror, m), box(self.local, l), self.guid, self.valueState.label, "]")
guard self.isFolder else {
return
}
print(ind, "[S: ", self.structureState.label, "]")
if let children = self.mergedChildren {
print(ind, " ..")
for child in children {
child.dump(indent + 2)
}
}
}
}
private func box<T>(_ x: T?, _ c: Character) -> Character {
if x == nil {
return ""
}
return c
}
private func indenting(_ by: Int) -> String {
return String(repeating: " ", count: by)
}
class MergedTree {
var root: MergedTreeNode
var deleteLocally: Set<GUID> = Set()
var deleteRemotely: Set<GUID> = Set()
var deleteFromMirror: Set<GUID> = Set()
var acceptLocalDeletion: Set<GUID> = Set()
var acceptRemoteDeletion: Set<GUID> = Set()
var allGUIDs: Set<GUID> {
var out = Set<GUID>([self.root.guid])
func acc(_ node: MergedTreeNode) {
guard let children = node.mergedChildren else {
return
}
out.formUnion(Set(children.map { $0.guid }))
children.forEach(acc)
}
acc(self.root)
return out
}
init(mirrorRoot: BookmarkTreeNode) {
self.root = MergedTreeNode(guid: mirrorRoot.recordGUID, mirror: mirrorRoot, structureState: MergeState.unchanged)
self.root.valueState = MergeState.unchanged
}
func dump() {
print("Deleted locally: \(self.deleteLocally.joined(separator: ", "))")
print("Deleted remotely: \(self.deleteRemotely.joined(separator: ", "))")
print("Deleted from mirror: \(self.deleteFromMirror.joined(separator: ", "))")
print("Accepted local deletions: \(self.acceptLocalDeletion.joined(separator: ", "))")
print("Accepted remote deletions: \(self.acceptRemoteDeletion.joined(separator: ", "))")
print("Root: ")
self.root.dump(0)
}
}

File diff suppressed because it is too large Load diff