mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-19 23:07:33 +09:00
Dactyloidae iOS initial commit
This commit is contained in:
parent
daa6179d22
commit
7154a0497e
2123 changed files with 197052 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
336
mobile/ios/Sync/Synchronizers/Bookmarks/Merging.swift
Normal file
336
mobile/ios/Sync/Synchronizers/Bookmarks/Merging.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1423
mobile/ios/Sync/Synchronizers/Bookmarks/ThreeWayTreeMerger.swift
Normal file
1423
mobile/ios/Sync/Synchronizers/Bookmarks/ThreeWayTreeMerger.swift
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue