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

View file

@ -0,0 +1,432 @@
/* 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 XCGLogger
import Deferred
import SwiftyJSON
private let log = Logger.syncLogger
let ClientsStorageVersion = 1
// TODO
public protocol Command {
static func fromName(_ command: String, args: [JSON]) -> Command?
func run(_ synchronizer: ClientsSynchronizer) -> Success
static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command?
}
// Shit.
// We need a way to wipe or reset engines.
// We need a way to log out the account.
// So when we sync commands, we're gonna need a delegate of some kind.
open class WipeCommand: Command {
public init?(command: String, args: [JSON]) {
return nil
}
open class func fromName(_ command: String, args: [JSON]) -> Command? {
return WipeCommand(command: command, args: args)
}
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
return succeed()
}
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
let json = JSON(parseJSON: syncCommand.value)
if let name = json["command"].string,
let args = json["args"].array {
return WipeCommand.fromName(name, args: args)
}
return nil
}
}
open class DisplayURICommand: Command {
let uri: URL
let title: String
let sender: String
public init?(command: String, args: [JSON]) {
if let uri = args[0].string?.asURL,
let sender = args[1].string,
let title = args[2].string {
self.uri = uri
self.sender = sender
self.title = title
} else {
// Oh, Swift.
self.uri = "http://localhost/".asURL!
self.title = ""
return nil
}
}
open class func fromName(_ command: String, args: [JSON]) -> Command? {
return DisplayURICommand(command: command, args: args)
}
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
func display(_ deviceName: String? = nil) -> Success {
synchronizer.delegate.displaySentTab(for: uri, title: title, from: deviceName)
return succeed()
}
guard let getClientWithId = synchronizer.localClients?.getClientWithId(sender) else {
return display()
}
return getClientWithId >>== { client in
return display(client?.name)
}
}
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
let json = JSON(parseJSON: syncCommand.value)
if let name = json["command"].string,
let args = json["args"].array {
return DisplayURICommand.fromName(name, args: args)
}
return nil
}
}
open class RepairResponseCommand: Command {
let repairResponse: RepairResponse
public init(command: String, args: [JSON]) {
self.repairResponse = RepairResponse.fromJSON(args: args[0])
}
open class func fromName(_ command: String, args: [JSON]) -> Command? {
return RepairResponseCommand(command: command, args: args)
}
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
let repairer = BookmarksRepairRequestor(scratchpad: synchronizer.scratchpad, basePrefs: synchronizer.basePrefs, remoteClients: synchronizer.localClients!)
return repairer.continueRepairs(response: self.repairResponse) >>> succeed
}
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
let json = JSON(parseJSON: syncCommand.value)
if let name = json["command"].string,
let args = json["args"].array {
return RepairResponseCommand.fromName(name, args: args)
}
return nil
}
}
let Commands: [String: (String, [JSON]) -> Command?] = [
"wipeAll": WipeCommand.fromName,
"wipeEngine": WipeCommand.fromName,
// resetEngine
// resetAll
// logout
"displayURI": DisplayURICommand.fromName,
"repairResponse": RepairResponseCommand.fromName
]
open class ClientsSynchronizer: TimestampedSingleCollectionSynchronizer, Synchronizer {
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "clients")
}
var localClients: RemoteClientsAndTabs?
override var storageVersion: Int {
return ClientsStorageVersion
}
var clientRecordLastUpload: Timestamp {
set(value) {
self.prefs.setLong(value, forKey: "lastClientUpload")
}
get {
return self.prefs.unsignedLongForKey("lastClientUpload") ?? 0
}
}
// Sync Object Format (Version 1) for Form Factors: http://docs.services.mozilla.com/sync/objectformats.html#id2
fileprivate enum SyncFormFactorFormat: String {
case phone = "phone"
case tablet = "tablet"
}
open func getOurClientRecord() -> Record<ClientPayload> {
let guid = self.scratchpad.clientGUID
let formfactor = formFactorString()
let json = JSON(object: [
"id": guid,
"fxaDeviceId": self.scratchpad.fxaDeviceId,
"version": AppInfo.appVersion,
"protocols": ["1.5"],
"name": self.scratchpad.clientName,
"os": "iOS",
"commands": [JSON](),
"type": "mobile",
"appPackage": AppInfo.baseBundleIdentifier,
"application": AppInfo.displayName,
"device": DeviceInfo.deviceModel(),
"formfactor": formfactor])
let payload = ClientPayload(json)
return Record(id: guid, payload: payload, ttl: ThreeWeeksInSeconds)
}
fileprivate func formFactorString() -> String {
let userInterfaceIdiom = UIDevice.current.userInterfaceIdiom
var formfactor: String
switch userInterfaceIdiom {
case .phone:
formfactor = SyncFormFactorFormat.phone.rawValue
case .pad:
formfactor = SyncFormFactorFormat.tablet.rawValue
default:
formfactor = SyncFormFactorFormat.phone.rawValue
}
return formfactor
}
fileprivate func clientRecordToLocalClientEntry(_ record: Record<ClientPayload>) -> RemoteClient {
let modified = record.modified
let payload = record.payload
return RemoteClient(json: payload.json, modified: modified)
}
// If this is a fresh start, do a wipe.
// N.B., we don't wipe outgoing commands! (TODO: check this when we implement commands!)
// N.B., but perhaps we should discard outgoing wipe/reset commands!
fileprivate func wipeIfNecessary(_ localClients: RemoteClientsAndTabs) -> Success {
if self.lastFetched == 0 {
return localClients.wipeClients()
}
return succeed()
}
/**
* Returns whether any commands were found (and thus a replacement record
* needs to be uploaded). Also returns the commands: we run them after we
* upload a replacement record.
*/
fileprivate func processCommandsFromRecord(_ record: Record<ClientPayload>?, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Deferred<Maybe<(Bool, [Command])>> {
log.debug("Processing commands from downloaded record.")
// TODO: short-circuit based on the modified time of the record we uploaded, so we don't need to skip ahead.
if let record = record {
let commands = record.payload.commands
if !commands.isEmpty {
func parse(_ json: JSON) -> Command? {
if let name = json["command"].string,
let args = json["args"].array,
let constructor = Commands[name] {
return constructor(name, args)
}
return nil
}
// TODO: can we do anything better if a command fails?
return deferMaybe((true, optFilter(commands.map(parse))))
}
}
return deferMaybe((false, []))
}
fileprivate func uploadClientCommands(toLocalClients localClients: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
return localClients.getCommands() >>== { clientCommands in
return clientCommands.map { (clientGUID, commands) -> Success in
self.syncClientCommands(clientGUID, commands: commands, clientsAndTabs: localClients, withServer: storageClient)
}.allSucceed()
}
}
fileprivate func syncClientCommands(_ clientGUID: GUID, commands: [SyncCommand], clientsAndTabs: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
let deleteCommands: () -> Success = {
return clientsAndTabs.deleteCommands(clientGUID).bind({ x in return succeed() })
}
log.debug("Fetching current client record for client \(clientGUID).")
let fetch = storageClient.get(clientGUID)
return fetch.bind() { result in
if let response = result.successValue, response.value.payload.isValid() {
let record = response.value
if var clientRecord = record.payload.json.dictionary {
clientRecord["commands"] = JSON(record.payload.commands + commands.map { JSON(parseJSON: $0.value) })
let uploadRecord = Record(id: clientGUID, payload: ClientPayload(JSON(clientRecord)), ttl: ThreeWeeksInSeconds)
return storageClient.put(uploadRecord, ifUnmodifiedSince: record.modified)
>>== { resp in
log.debug("Client \(clientGUID) commands upload succeeded.")
// Always succeed, even if we couldn't delete the commands.
return deleteCommands()
}
}
} else {
if let failure = result.failureValue {
log.warning("Failed to fetch record with GUID \(clientGUID).")
if failure is NotFound<HTTPURLResponse> {
log.debug("Not waiting to see if the client comes back.")
// TODO: keep these around and retry, expiring after a while.
// For now we just throw them away so we don't fail every time.
return deleteCommands()
}
if failure is BadRequestError<HTTPURLResponse> {
log.debug("We made a bad request. Throwing away queued commands.")
return deleteCommands()
}
}
}
log.error("Client \(clientGUID) commands upload failed: No remote client for GUID")
return deferMaybe(UnknownError())
}
}
/**
* Upload our record if either (a) we know we should upload, or (b)
* our own notes tell us we're due to reupload.
*/
fileprivate func maybeUploadOurRecord(_ should: Bool, ifUnmodifiedSince: Timestamp?, toServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
let lastUpload = self.clientRecordLastUpload
let expired = lastUpload < (Date.now() - (2 * OneDayInMilliseconds))
log.debug("Should we upload our client record? Caller = \(should), expired = \(expired).")
if !should && !expired {
return succeed()
}
let iUS: Timestamp? = ifUnmodifiedSince ?? ((lastUpload == 0) ? nil : lastUpload)
var uploadStats = SyncUploadStats()
return storageClient.put(getOurClientRecord(), ifUnmodifiedSince: iUS)
>>== { resp in
if let ts = resp.metadata.lastModifiedMilliseconds {
// Protocol says this should always be present for success responses.
log.debug("Client record upload succeeded. New timestamp: \(ts).")
self.clientRecordLastUpload = ts
uploadStats.sent += 1
} else {
uploadStats.sentFailed += 1
}
self.statsSession.recordUpload(stats: uploadStats)
return succeed()
}
}
fileprivate func applyStorageResponse(_ response: StorageResponse<[Record<ClientPayload>]>, toLocalClients localClients: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>, notifier: CollectionChangedNotifier?) -> Success {
log.debug("Applying clients response.")
var downloadStats = SyncDownloadStats()
let records = response.value
let responseTimestamp = response.metadata.lastModifiedMilliseconds
log.debug("Got \(records.count) client records.")
let ourGUID = self.scratchpad.clientGUID
var toInsert = [RemoteClient]()
var ours: Record<ClientPayload>? = nil
for (rec) in records {
guard rec.payload.isValid() else {
log.warning("Client record \(rec.id) is invalid. Skipping.")
continue
}
if rec.id == ourGUID {
if rec.modified == self.clientRecordLastUpload {
log.debug("Skipping our own unmodified record.")
} else {
log.debug("Saw our own record in response.")
ours = rec
}
} else {
toInsert.append(self.clientRecordToLocalClientEntry(rec))
}
}
downloadStats.applied += toInsert.count
// Apply remote changes.
// Collect commands from our own record and reupload if necessary.
// Then run the commands and return.
return localClients.insertOrUpdateClients(toInsert)
>>== { succeeded in
downloadStats.succeeded += succeeded
downloadStats.failed += (toInsert.count - succeeded)
self.statsSession.recordDownload(stats: downloadStats)
return succeed()
}
>>== { self.processCommandsFromRecord(ours, withServer: storageClient) }
>>== { (shouldUpload, commands) in
let isFirstSync = self.lastFetched == 0
return self.maybeUploadOurRecord(shouldUpload || self.why == .didLogin, ifUnmodifiedSince: ours?.modified, toServer: storageClient)
>>> { self.uploadClientCommands(toLocalClients: localClients, withServer: storageClient) }
>>> {
log.debug("Running \(commands.count) commands.")
for command in commands {
_ = command.run(self)
}
self.lastFetched = responseTimestamp!
if isFirstSync,
let notifier = notifier {
DispatchQueue.global(qos: DispatchQoS.background.qosClass).async { _ = notifier.notifyAll(collectionsChanged: ["clients"], reason: "firstsync") }
}
return succeed()
}
}
}
open func synchronizeLocalClients(_ localClients: RemoteClientsAndTabs, withServer storageClient: Sync15StorageClient, info: InfoCollections, notifier: CollectionChangedNotifier?) -> SyncResult {
log.debug("Synchronizing clients.")
self.localClients = localClients // Store for later when we process a repairResponse command
if let reason = self.reasonToNotSync(storageClient) {
switch reason {
case .engineRemotelyNotEnabled:
// This is a hard error for us.
return deferMaybe(FatalError(message: "clients not mentioned in meta/global. Server wiped?"))
default:
return deferMaybe(SyncStatus.notStarted(reason))
}
}
let keys = self.scratchpad.keys?.value
let encoder = RecordEncoder<ClientPayload>(decode: { ClientPayload($0) }, encode: { $0.json })
let encrypter = keys?.encrypter(self.collection, encoder: encoder)
if encrypter == nil {
log.error("Couldn't make clients encrypter.")
return deferMaybe(FatalError(message: "Couldn't make clients encrypter."))
}
let clientsClient = storageClient.clientForCollection(self.collection, encrypter: encrypter!)
// TODO: some of the commands we process might involve wiping collections or the
// entire profile. We should model this as an explicit status, and return it here
// instead of .completed.
statsSession.start()
// XXX: This is terrible. We always force a re-sync of the clients to work around
// the fact that `fxaDeviceId` may not have been populated if the list of clients
// hadn't changed since before the update to v8.0. To force a re-sync, we get all
// clients since the beginning of time instead of looking at `self.lastFetched`.
return clientsClient.getSince(0)
>>== { response in
return self.wipeIfNecessary(localClients)
>>> { self.applyStorageResponse(response, toLocalClients: localClients, withServer: clientsClient, notifier: notifier) }
}
>>> { deferMaybe(self.completedWithStats) }
}
}

View file

@ -0,0 +1,231 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
class BatchingDownloader<T: CleartextPayloadJSON> {
let client: Sync15CollectionClient<T>
let collection: String
let prefs: Prefs
var batch: [Record<T>] = []
func store(_ records: [Record<T>]) {
self.batch += records
}
func retrieve() -> [Record<T>] {
let ret = self.batch
self.batch = []
return ret
}
var _advance: (() -> Void)?
func advance() {
guard let f = self._advance else {
return
}
self._advance = nil
f()
}
init(collectionClient: Sync15CollectionClient<T>, basePrefs: Prefs, collection: String) {
self.client = collectionClient
self.collection = collection
let branchName = "downloader." + collection + "."
self.prefs = basePrefs.branch(branchName)
log.info("Downloader configured with prefs '\(self.prefs.getBranchPrefix())'.")
}
static func resetDownloaderWithPrefs(_ basePrefs: Prefs, collection: String) {
// This leads to stupid paths like 'profile.sync.synchronizer.history..downloader.history..'.
// Sorry, but it's out in the world now...
let branchName = "downloader." + collection + "."
let prefs = basePrefs.branch(branchName)
let lm = prefs.timestampForKey("lastModified")
let bt = prefs.timestampForKey("baseTimestamp")
log.debug("Resetting downloader prefs \(prefs.getBranchPrefix()). Previous values: \(lm ??? "nil"), \(bt ??? "nil").")
prefs.removeObjectForKey("nextOffset")
prefs.removeObjectForKey("offsetNewer")
prefs.removeObjectForKey("baseTimestamp")
prefs.removeObjectForKey("lastModified")
}
/**
* Clients should provide the same set of parameters alongside an `offset` as was
* provided with the initial request. The only thing that varies in our batch fetches
* is `newer`, so we track the original value alongside.
*/
var nextFetchParameters: (String, Timestamp)? {
get {
let o = self.prefs.stringForKey("nextOffset")
let n = self.prefs.timestampForKey("offsetNewer")
guard let offset = o, let newer = n else {
return nil
}
return (offset, newer)
}
set (value) {
if let (offset, newer) = value {
self.prefs.setString(offset, forKey: "nextOffset")
self.prefs.setTimestamp(newer, forKey: "offsetNewer")
} else {
self.prefs.removeObjectForKey("nextOffset")
self.prefs.removeObjectForKey("offsetNewer")
}
}
}
// Set after each batch, from record timestamps.
var baseTimestamp: Timestamp {
get {
return self.prefs.timestampForKey("baseTimestamp") ?? 0
}
set (value) {
self.prefs.setTimestamp(value, forKey: "baseTimestamp")
}
}
// Only set at the end of a batch, from headers.
var lastModified: Timestamp {
get {
return self.prefs.timestampForKey("lastModified") ?? 0
}
set (value) {
self.prefs.setTimestamp(value, forKey: "lastModified")
}
}
/**
* Call this when a significant structural server change has been detected.
*/
func reset() -> Success {
self.baseTimestamp = 0
self.lastModified = 0
self.nextFetchParameters = nil
self.batch = []
self._advance = nil
return succeed()
}
func go(_ info: InfoCollections, limit: Int) -> Deferred<Maybe<DownloadEndState>> {
guard let modified = info.modified(self.collection) else {
log.debug("No server modified time for collection \(self.collection).")
return deferMaybe(.noNewData)
}
log.debug("Modified: \(modified); last \(self.lastModified).")
if modified == self.lastModified {
log.debug("No more data to batch-download.")
return deferMaybe(.noNewData)
}
// If the caller hasn't advanced after the last batch, strange things will happen --
// potentially looping indefinitely. Warn.
if self._advance != nil && !self.batch.isEmpty {
log.warning("Downloading another batch without having advanced. This might be a bug.")
}
return self.downloadNextBatchWithLimit(limit, infoModified: modified)
}
func advanceTimestampTo(_ timestamp: Timestamp) {
log.debug("Advancing downloader lastModified from \(self.lastModified) to \(timestamp).")
self.lastModified = timestamp
}
// We're either fetching from our current base timestamp with no offset,
// or the timestamp we were using when we last saved an offset.
func fetchParameters() -> (String?, Timestamp) {
if let (offset, since) = self.nextFetchParameters {
return (offset, since)
}
return (nil, max(self.lastModified, self.baseTimestamp))
}
func downloadNextBatchWithLimit(_ limit: Int, infoModified: Timestamp) -> Deferred<Maybe<DownloadEndState>> {
let (offset, since) = self.fetchParameters()
log.debug("Fetching newer=\(since), offset=\(offset ?? "nil").")
let fetch = self.client.getSince(since, sort: SortOption.OldestFirst, limit: limit, offset: offset)
func handleFailure(_ err: MaybeErrorType) -> Deferred<Maybe<DownloadEndState>> {
log.debug("Handling failure.")
guard let badRequest = err as? BadRequestError<[Record<T>]>, badRequest.response.metadata.status == 412 else {
// Just pass through the failure.
return deferMaybe(err)
}
// Conflict. Start again.
log.warning("Server contents changed during offset-based batching. Stepping back.")
self.nextFetchParameters = nil
return deferMaybe(.interrupted)
}
func handleSuccess(_ response: StorageResponse<[Record<T>]>) -> Deferred<Maybe<DownloadEndState>> {
log.debug("Handling success.")
let nextOffset = response.metadata.nextOffset
let responseModified = response.value.last?.modified
// Queue up our metadata advance. We wait until the consumer has fetched
// and processed this batch; they'll call .advance() on success.
self._advance = {
// Shift to the next offset. This might be nil, in which case fine!
// Note that we preserve the previous 'newer' value from the offset or the original fetch,
// even as we update baseTimestamp.
self.nextFetchParameters = nextOffset == nil ? nil : (nextOffset!, since)
// If there are records, advance to just before the timestamp of the last.
// If our next fetch with X-Weave-Next-Offset fails, at least we'll start here.
//
// This approach is only valid if we're fetching oldest-first.
if let newBase = responseModified {
log.debug("Advancing baseTimestamp to \(newBase) - 1")
self.baseTimestamp = newBase - 1
}
if nextOffset == nil {
// If we can't get a timestamp from the header -- and we should always be able to --
// we fall back on the collection modified time in i/c, as supplied by the caller.
// In any case where there is no racing writer these two values should be the same.
// If they differ, the header should be later. If it's missing, and we use the i/c
// value, we'll simply redownload some records.
// All bets are off if we hit this case and are filtering somehow don't do that.
let lm = response.metadata.lastModifiedMilliseconds
log.debug("Advancing lastModified to \(String(describing: lm)) ?? \(infoModified).")
self.lastModified = lm ?? infoModified
}
}
log.debug("Got success response with \(response.metadata.records ?? 0) records.")
// Store the incoming records for collection.
self.store(response.value)
return deferMaybe(nextOffset == nil ? .complete : .incomplete)
}
return fetch.bind { result in
guard let response = result.successValue else {
return handleFailure(result.failureValue!)
}
return handleSuccess(response)
}
}
}
public enum DownloadEndState: String {
case complete // We're done. Records are waiting for you.
case incomplete // applyBatch was called, and we think there are more records.
case noNewData // There were no records.
case interrupted // We got a 412 conflict when fetching the next batch.
}

View file

@ -0,0 +1,287 @@
/* 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 XCGLogger
import Deferred
import SwiftyJSON
private let log = Logger.syncLogger
private let HistoryTTLInSeconds = 5184000 // 60 days.
let HistoryStorageVersion = 1
func makeDeletedHistoryRecord(_ guid: GUID) -> Record<HistoryPayload> {
// Local modified time is ignored in upload serialization.
let modified: Timestamp = 0
// Sortindex for history is frecency. Make deleted items more frecent than almost
// anything.
let sortindex = 5_000_000
let ttl = HistoryTTLInSeconds
let json: JSON = JSON([
"id": guid,
"deleted": true,
])
let payload = HistoryPayload(json)
return Record<HistoryPayload>(id: guid, payload: payload, modified: modified, sortindex: sortindex, ttl: ttl)
}
func makeHistoryRecord(_ place: Place, visits: [Visit]) -> Record<HistoryPayload> {
let id = place.guid
let modified: Timestamp = 0 // Ignored in upload serialization.
let sortindex = 1 // TODO: frecency!
let ttl = HistoryTTLInSeconds
let json: JSON = JSON([
"id": id,
"visits": visits.map { $0.toJSON() },
"histUri": place.url,
"title": place.title,
])
let payload = HistoryPayload(json)
return Record<HistoryPayload>(id: id, payload: payload, modified: modified, sortindex: sortindex, ttl: ttl)
}
open class HistorySynchronizer: IndependentRecordSynchronizer, Synchronizer {
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "history")
}
override var storageVersion: Int {
return HistoryStorageVersion
}
fileprivate let batchSize: Int = 1000 // A balance between number of requests and per-request size.
fileprivate func mask(_ maxFailures: Int) -> (Maybe<()>) -> Success {
var failures = 0
return { result in
if result.isSuccess {
return Deferred(value: result)
}
failures += 1
if failures > maxFailures {
return Deferred(value: result)
}
log.debug("Masking failure \(failures).")
return succeed()
}
}
// TODO: this function should establish a transaction at suitable points.
// TODO: a much more efficient way to do this is to:
// 1. Start a transaction.
// 2. Try to update each place. Note failures.
// 3. bulkInsert all failed updates in one go.
// 4. Store all remote visits for all places in one go, constructing a single sequence of visits.
func applyIncomingToStorage(_ storage: SyncableHistory, records: [Record<HistoryPayload>]) -> Success {
// Skip over at most this many failing records before aborting the sync.
let maskSomeFailures = self.mask(3)
// TODO: it'd be nice to put this in an extension on SyncableHistory. Waiting for Swift 2.0...
func applyRecord(_ rec: Record<HistoryPayload>) -> Success {
let guid = rec.id
let payload = rec.payload
let modified = rec.modified
// We apply deletions immediately. Yes, this will throw away local visits
// that haven't yet been synced. That's how Sync works, alas.
if payload.deleted {
return storage.deleteByGUID(guid, deletedAt: modified).bind(maskSomeFailures)
}
// It's safe to apply other remote records, too -- even if we re-download, we know
// from our local cached server timestamp on each record that we've already seen it.
// We have to reconcile on-the-fly: we're about to overwrite the server record, which
// is our shared parent.
let place = rec.payload.asPlace()
if isIgnoredURL(place.url) {
log.debug("Ignoring incoming record \(guid) because its URL is one we wish to ignore.")
return succeed()
}
let placeThenVisits = storage.insertOrUpdatePlace(place, modified: modified)
>>> { storage.storeRemoteVisits(payload.visits, forGUID: guid) }
return placeThenVisits.map({ result in
if result.isFailure {
let reason = result.failureValue?.description ?? "unknown reason"
log.error("Record application failed: \(reason)")
}
return result
}).bind(maskSomeFailures)
}
return self.applyIncomingRecords(records, apply: applyRecord)
}
fileprivate func uploadModifiedPlaces(_ places: [(Place, [Visit])], lastTimestamp: Timestamp, fromStorage storage: SyncableHistory, withServer storageClient: Sync15CollectionClient<HistoryPayload>) -> DeferredTimestamp {
log.info("Preparing upload…")
// Build sequences of 1000 history items, sequence by sequence
// These will be uploaded in smaller batches by the upload batcher, but we chunk here
// in order to bound peak memory usage when we call makeHistoryRecord below.
let toUpload = chunk(places, by: 1000)
let perChunk: (ArraySlice<(Place, [Visit])>, Timestamp) -> DeferredTimestamp = { (records, timestamp) in
let recs = records.map(makeHistoryRecord)
log.info("Uploading \(recs.count) history items…")
return self.uploadRecords(recs, lastTimestamp: timestamp, storageClient: storageClient) { result, lastModified in
// We don't do anything with failed.
return storage.markAsSynchronized(result.success, modified: lastModified ?? timestamp)
}
}
let start = deferMaybe(lastTimestamp)
return walk(toUpload, start: start, f: perChunk)
}
fileprivate func uploadDeletedPlaces(_ guids: [GUID], lastTimestamp: Timestamp, fromStorage storage: SyncableHistory, withServer storageClient: Sync15CollectionClient<HistoryPayload>) -> DeferredTimestamp {
let records = guids.map(makeDeletedHistoryRecord)
// Deletions are smaller, so upload 100 at a time.
return self.uploadRecords(records, lastTimestamp: lastTimestamp, storageClient: storageClient) { result, lastModified in
storage.markAsDeleted(result.success) >>> always(lastModified ?? lastTimestamp)
}
}
fileprivate func uploadOutgoingFromStorage(_ storage: SyncableHistory, lastTimestamp: Timestamp, withServer storageClient: Sync15CollectionClient<HistoryPayload>) -> Success {
var workWasDone = false
let uploadDeleted: (Timestamp) -> DeferredTimestamp = { timestamp in
storage.getDeletedHistoryToUpload()
>>== { guids in
if !guids.isEmpty {
workWasDone = true
}
log.info("Uploading \(guids.count) deleted places.")
return self.uploadDeletedPlaces(guids, lastTimestamp: timestamp, fromStorage: storage, withServer: storageClient)
}
}
let uploadModified: (Timestamp) -> DeferredTimestamp = { timestamp in
storage.getModifiedHistoryToUpload()
>>== { places in
if !places.isEmpty {
workWasDone = true
}
log.info("Uploading \(places.count) modified places.")
return self.uploadModifiedPlaces(places, lastTimestamp: timestamp, fromStorage: storage, withServer: storageClient)
}
}
// The last clause will checkpoint the DB. But we just checkpointed the DB after downloading records!
// Yes, that's true. Either there will be lots of work to do (e.g., having just marked
// thousands of records as uploaded, or dropping lots of deleted rows), and so it's
// worthwhile or there won't be much work to do, and the checkpoint will be cheap.
// If we did nothing -- uploaded no deletions, uploaded no modified records -- then we
// don't checkpoint at all.
return deferMaybe(lastTimestamp)
>>== uploadDeleted
>>== uploadModified
>>> effect({ log.debug("Done syncing. Work was done? \(workWasDone)") })
>>> { workWasDone ? storage.doneUpdatingMetadataAfterUpload() : succeed() } // A closure so we eval workWasDone after it's set!
>>> effect({ log.debug("Done.") })
}
/**
* If the green light turns red, we don't want to continue to upload -- doing
* so would cause us to fast-forward our last sync timestamp and skip whatever
* we hadn't yet downloaded.
*/
fileprivate func go(_ info: InfoCollections, greenLight: @escaping () -> Bool, downloader: BatchingDownloader<HistoryPayload>, history: SyncableHistory) -> SyncResult {
if !greenLight() {
log.info("Green light turned red. Stopping history download.")
return deferMaybe(.partial(self.statsSession))
}
func applyBatched() -> Success {
return self.applyIncomingToStorage(history, records: downloader.retrieve())
>>> effect(downloader.advance)
}
func onBatchResult(_ result: Maybe<DownloadEndState>) -> SyncResult {
guard let end = result.successValue else {
log.warning("Got failure: \(result.failureValue!)")
return deferMaybe(completedWithStats)
}
switch end {
case .complete:
log.info("Done with batched mirroring.")
return applyBatched()
>>> history.doneApplyingRecordsAfterDownload
>>> { deferMaybe(self.completedWithStats) }
case .incomplete:
log.debug("Running another batch.")
// This recursion is fine because Deferred always pushes callbacks onto a queue.
return applyBatched()
>>> { self.go(info, greenLight: greenLight, downloader: downloader, history: history) }
case .interrupted:
log.info("Interrupted. Aborting batching this time.")
return deferMaybe(.partial(self.statsSession))
case .noNewData:
log.info("No new data. No need to continue batching.")
downloader.advance()
return deferMaybe(completedWithStats)
}
}
return downloader.go(info, limit: self.batchSize)
.bind(onBatchResult)
}
open func synchronizeLocalHistory(_ history: SyncableHistory, withServer storageClient: Sync15StorageClient, info: InfoCollections, greenLight: @escaping () -> Bool) -> SyncResult {
if let reason = self.reasonToNotSync(storageClient) {
return deferMaybe(.notStarted(reason))
}
let encoder = RecordEncoder<HistoryPayload>(decode: { HistoryPayload($0) }, encode: { $0.json })
guard let historyClient = self.collectionClient(encoder, storageClient: storageClient) else {
log.error("Couldn't make history factory.")
return deferMaybe(FatalError(message: "Couldn't make history factory."))
}
let downloader = BatchingDownloader(collectionClient: historyClient, basePrefs: self.prefs, collection: "history")
// The original version of the history synchronizer tracked its
// own last fetched time. We need to migrate this into the
// batching downloader.
let since: Timestamp = self.lastFetched
if since > downloader.lastModified {
log.debug("Advancing downloader lastModified to synchronizer lastFetched \(since).")
downloader.lastModified = since
self.lastFetched = 0
}
statsSession.start()
return self.go(info, greenLight: greenLight, downloader: downloader, history: history)
>>== { syncResult in
switch syncResult {
case .completed:
// When we're done downloading, we can upload.
return self.uploadOutgoingFromStorage(history,
lastTimestamp: 0,
withServer: historyClient)
>>> { deferMaybe(self.completedWithStats) }
// If we didn't finish downloading, do nothing further -- just pass
// through the download result.
case .notStarted(_):
return deferMaybe(syncResult)
case .partial:
log.debug("Didn't finish downloading history; not uploading yet.")
return deferMaybe(syncResult)
}
}
}
}

View file

@ -0,0 +1,147 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
class Uploader {
/**
* Upload just about anything that can be turned into something we can upload.
*/
func sequentialPosts<T>(_ items: [T], by: Int, lastTimestamp: Timestamp, storageOp: @escaping ([T], Timestamp) -> DeferredTimestamp) -> DeferredTimestamp {
// This needs to be a real Array, not an ArraySlice,
// for the types to line up.
let chunks = chunk(items, by: by).map { Array($0) }
let start = deferMaybe(lastTimestamp)
let perChunk: ([T], Timestamp) -> DeferredTimestamp = { (records, timestamp) in
// TODO: detect interruptions -- clients uploading records during our sync --
// by using ifUnmodifiedSince. We can detect uploaded records since our download
// (chain the download timestamp into this function), and we can detect uploads
// that race with our own (chain download timestamps across 'walk' steps).
// If we do that, we can also advance our last fetch timestamp after each chunk.
log.debug("Uploading \(records.count) records.")
return storageOp(records, timestamp)
}
return walk(chunks, start: start, f: perChunk)
}
}
open class IndependentRecordSynchronizer: TimestampedSingleCollectionSynchronizer {
private func reportApplyStatsWrap<T>(apply: @escaping (T) -> Success) -> (T) -> Success {
return { record in
return apply(record).bind({ result in
var stats = SyncDownloadStats()
stats.applied = 1
if result.isSuccess {
stats.succeeded = 1
} else {
stats.failed = 1
}
self.statsSession.recordDownload(stats: stats)
return Deferred(value: result)
})
}
}
/**
* Just like the usual applyIncomingToStorage, but doesn't fast-forward the timestamp.
*/
func applyIncomingRecords<T>(_ records: [T], apply: @escaping (T) -> Success) -> Success {
if records.isEmpty {
log.debug("No records; done applying.")
return succeed()
}
return walk(records, f: reportApplyStatsWrap(apply: apply))
}
func applyIncomingToStorage<T>(_ records: [T], fetched: Timestamp, apply: @escaping (T) -> Success) -> Success {
func done() -> Success {
log.debug("Bumping fetch timestamp to \(fetched).")
self.lastFetched = fetched
return succeed()
}
if records.isEmpty {
log.debug("No records; done applying.")
return done()
}
return walk(records, f: reportApplyStatsWrap(apply: apply)) >>> done
}
}
extension TimestampedSingleCollectionSynchronizer {
/**
* On each chunk that we upload, we pass along the server modified timestamp to the next,
* chained through the provided `onUpload` function.
*
* The last chunk passes this modified timestamp out, and we assign it to lastFetched.
*
* The idea of this is twofold:
*
* 1. It does the fast-forwarding that every other Sync client does.
*
* 2. It allows us to (eventually) pass the last collection modified time as If-Unmodified-Since
* on each upload batch, as we do between the download and the upload phase.
* This alone allows us to detect conflicts from racing clients.
*
* In order to implement the latter, we'd need to chain the date from getSince in place of the
* 0 in the call to uploadOutgoingFromStorage in each synchronizer.
*/
func uploadRecords<T>(_ records: [Record<T>], lastTimestamp: Timestamp, storageClient: Sync15CollectionClient<T>, onUpload: @escaping (POSTResult, Timestamp?) -> DeferredTimestamp) -> DeferredTimestamp {
if records.isEmpty {
log.debug("No modified records to upload.")
return deferMaybe(lastTimestamp)
}
func reportUploadStatsWrap(result: POSTResult, timestamp: Timestamp?) -> DeferredTimestamp {
let stats = SyncUploadStats(sent: result.success.count, sentFailed: result.failed.count)
self.statsSession.recordUpload(stats: stats)
return onUpload(result, timestamp)
}
let batch = storageClient.newBatch(ifUnmodifiedSince: (lastTimestamp == 0) ? nil : lastTimestamp, onCollectionUploaded: reportUploadStatsWrap)
return batch.addRecords(records)
>>> batch.endBatch
>>> {
let timestamp = batch.ifUnmodifiedSince ?? lastTimestamp
self.setTimestamp(timestamp)
return deferMaybe(timestamp)
}
}
func uploadRecordsSingleBatch<T>(_ records: [Record<T>], lastTimestamp: Timestamp, storageClient: Sync15CollectionClient<T>) -> Deferred<Maybe<(timestamp: Timestamp, succeeded: [GUID])>> {
if records.isEmpty {
log.debug("No modified records to upload.")
return deferMaybe((timestamp: lastTimestamp, succeeded: []))
}
func reportUploadStatsWrap(result: POSTResult, timestamp: Timestamp?) -> DeferredTimestamp {
let stats = SyncUploadStats(sent: result.success.count, sentFailed: result.failed.count)
self.statsSession.recordUpload(stats: stats)
return deferMaybe(timestamp ?? lastTimestamp)
}
let batch = storageClient.newBatch(ifUnmodifiedSince: (lastTimestamp == 0) ? nil : lastTimestamp, onCollectionUploaded: reportUploadStatsWrap)
return batch.addRecords(records, singleBatch: true)
>>== batch.endSingleBatch
>>== { (succeeded, lastModified) in
guard let timestamp = lastModified else {
return deferMaybe(FatalError(message: "Could not retrieve lastModified from the server response."))
}
self.setTimestamp(timestamp)
return deferMaybe((timestamp: timestamp, succeeded: succeeded))
}
}
}

View file

@ -0,0 +1,190 @@
/* 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 XCGLogger
import Deferred
import SwiftyJSON
private let log = Logger.syncLogger
let PasswordsStorageVersion = 1
private func makeDeletedLoginRecord(_ guid: GUID) -> Record<LoginPayload> {
// Local modified time is ignored in upload serialization.
let modified: Timestamp = 0
// Arbitrary large number: deletions sync down first.
let sortindex = 5_000_000
let json: JSON = JSON([
"id": guid,
"deleted": true,
])
let payload = LoginPayload(json)
return Record<LoginPayload>(id: guid, payload: payload, modified: modified, sortindex: sortindex)
}
func makeLoginRecord(_ login: Login) -> Record<LoginPayload> {
let id = login.guid
let modified: Timestamp = 0 // Ignored in upload serialization.
let sortindex = 1
let tLU = NSNumber(value: login.timeLastUsed / 1000)
let tPC = NSNumber(value: login.timePasswordChanged / 1000)
let tC = NSNumber(value: login.timeCreated / 1000)
let dict: [String: Any] = [
"id": id,
"hostname": login.hostname,
"httpRealm": login.httpRealm as Any,
"formSubmitURL": login.formSubmitURL as Any,
"username": login.username ?? "",
"password": login.password ,
"usernameField": login.usernameField ?? "",
"passwordField": login.passwordField ?? "",
"timesUsed": login.timesUsed,
"timeLastUsed": tLU,
"timePasswordChanged": tPC,
"timeCreated": tC,
]
let payload = LoginPayload(JSON(dict))
return Record<LoginPayload>(id: id, payload: payload, modified: modified, sortindex: sortindex)
}
/**
* Our current local terminology ("logins") has diverged from the terminology in
* use when Sync was built ("passwords"). I've done my best to draw a reasonable line
* between the server collection/record format/etc. and local stuff: local storage
* works with logins, server records and collection are passwords.
*/
open class LoginsSynchronizer: IndependentRecordSynchronizer, Synchronizer {
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "passwords")
}
override var storageVersion: Int {
return PasswordsStorageVersion
}
func getLogin(_ record: Record<LoginPayload>) -> ServerLogin {
let guid = record.id
let payload = record.payload
let modified = record.modified
let login = ServerLogin(guid: guid, hostname: payload.hostname, username: payload.username, password: payload.password, modified: modified)
login.formSubmitURL = payload.formSubmitURL
login.httpRealm = payload.httpRealm
login.usernameField = payload.usernameField
login.passwordField = payload.passwordField
// Microseconds locally, milliseconds remotely. We should clean this up.
login.timeCreated = 1000 * (payload.timeCreated ?? 0)
login.timeLastUsed = 1000 * (payload.timeLastUsed ?? 0)
login.timePasswordChanged = 1000 * (payload.timePasswordChanged ?? 0)
login.timesUsed = payload.timesUsed ?? 0
return login
}
func applyIncomingToStorage(_ storage: SyncableLogins, records: [Record<LoginPayload>], fetched: Timestamp) -> Success {
return self.applyIncomingToStorage(records, fetched: fetched) { rec in
let guid = rec.id
let payload = rec.payload
guard payload.isValid() else {
log.warning("Login record \(guid) is invalid. Skipping.")
return succeed()
}
// We apply deletions immediately. That might not be exactly what we want -- perhaps you changed
// a password locally after deleting it remotely -- but it's expedient.
if payload.deleted {
return storage.deleteByGUID(guid, deletedAt: rec.modified)
}
return storage.applyChangedLogin(self.getLogin(rec))
}
}
fileprivate func uploadChangedRecords<T>(_ deleted: Set<GUID>, modified: Set<GUID>, records: [Record<T>], lastTimestamp: Timestamp, storage: SyncableLogins, withServer storageClient: Sync15CollectionClient<T>) -> Success {
let onUpload: (POSTResult, Timestamp?) -> DeferredTimestamp = { result, lastModified in
let uploaded = Set(result.success)
return storage.markAsDeleted(uploaded.intersection(deleted)) >>> { storage.markAsSynchronized(uploaded.intersection(modified), modified: lastModified ?? lastTimestamp) }
}
return uploadRecords(records,
lastTimestamp: lastTimestamp,
storageClient: storageClient,
onUpload: onUpload) >>> succeed
}
// Find any records for which a local overlay exists. If we want to be really precise,
// we can find the original server modified time for each record and use it as
// If-Unmodified-Since on a PUT, or just use the last fetch timestamp, which should
// be equivalent.
// We will already have reconciled any conflicts on download, so this upload phase should
// be as simple as uploading any changed or deleted items.
fileprivate func uploadOutgoingFromStorage(_ storage: SyncableLogins, lastTimestamp: Timestamp, withServer storageClient: Sync15CollectionClient<LoginPayload>) -> Success {
let deleted: () -> Deferred<Maybe<(Set<GUID>, [Record<LoginPayload>])>> = {
return storage.getDeletedLoginsToUpload() >>== { guids in
let records = guids.map(makeDeletedLoginRecord)
return deferMaybe((Set(guids), records))
}
}
let modified: () -> Deferred<Maybe<(Set<GUID>, [Record<LoginPayload>])>> = {
return storage.getModifiedLoginsToUpload() >>== { logins in
let guids = Set(logins.map { $0.guid })
let records = logins.map(makeLoginRecord)
return deferMaybe((guids, records))
}
}
return accumulate([deleted, modified]) >>== { result in
let (deletedGUIDs, deletedRecords) = result[0]
let (modifiedGUIDs, modifiedRecords) = result[1]
let allRecords = deletedRecords + modifiedRecords
return self.uploadChangedRecords(deletedGUIDs, modified: modifiedGUIDs, records: allRecords,
lastTimestamp: lastTimestamp, storage: storage, withServer: storageClient)
}
}
open func synchronizeLocalLogins(_ logins: SyncableLogins, withServer storageClient: Sync15StorageClient, info: InfoCollections) -> SyncResult {
if let reason = self.reasonToNotSync(storageClient) {
return deferMaybe(.notStarted(reason))
}
let encoder = RecordEncoder<LoginPayload>(decode: { LoginPayload($0) }, encode: { $0.json })
guard let passwordsClient = self.collectionClient(encoder, storageClient: storageClient) else {
log.error("Couldn't make logins factory.")
return deferMaybe(FatalError(message: "Couldn't make logins factory."))
}
let since: Timestamp = self.lastFetched
log.debug("Synchronizing \(self.collection). Last fetched: \(since).")
let applyIncomingToStorage: (StorageResponse<[Record<LoginPayload>]>) -> Success = { response in
let ts = response.metadata.timestampMilliseconds
let lm = response.metadata.lastModifiedMilliseconds!
log.debug("Applying incoming password records from response timestamped \(ts), last modified \(lm).")
log.debug("Records header hint: \(response.metadata.records ??? "nil")")
return self.applyIncomingToStorage(logins, records: response.value, fetched: lm) >>> effect {
NotificationCenter.default.post(name: NotificationDataRemoteLoginChangesWereApplied, object: nil)
}
}
statsSession.start()
return passwordsClient.getSince(since)
>>== applyIncomingToStorage
// TODO: If we fetch sorted by date, we can bump the lastFetched timestamp
// to the last successfully applied record timestamp, no matter where we fail.
// There's no need to do the upload before bumping -- the storage of local changes is stable.
>>> { self.uploadOutgoingFromStorage(logins, lastTimestamp: 0, withServer: passwordsClient) }
>>> { return deferMaybe(self.completedWithStats) }
}
}

View file

@ -0,0 +1,293 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
/**
* This exists to pass in external context: e.g., the UIApplication can
* expose notification functionality in this way.
*/
public protocol SyncDelegate {
func displaySentTab(for url: URL, title: String, from deviceName: String?)
// TODO: storage.
}
/**
* We sometimes want to make a synchronizer start from scratch: to throw away any
* metadata and reset storage to match, allowing us to respond to significant server
* changes.
*
* But instantiating a Synchronizer is a lot of work if we simply want to change some
* persistent state. This protocol describes a static func that fits most synchronizers.
*
* When the returned `Deferred` is filled with a success value, the supplied prefs and
* storage are ready to sync from scratch.
*
* Persisted long-term/local data is kept, and will later be reconciled as appropriate.
*/
public protocol ResettableSynchronizer {
static func resetSynchronizerWithStorage(_ storage: ResettableSyncStorage, basePrefs: Prefs, collection: String) -> Success
}
/**
* This is a delegate that allows synchronizers to notify other devices in the Sync account
* that a collection changed.
*/
public protocol CollectionChangedNotifier {
func notify(deviceIDs: [GUID], collectionsChanged collections: [String], reason: String) -> Success
func notifyAll(collectionsChanged collections: [String], reason: String) -> Success
}
// TODO: return values?
/**
* A Synchronizer is (unavoidably) entirely in charge of what it does within a sync.
* For example, it might make incremental progress in building a local cache of remote records, never actually performing an upload or modifying local storage.
* It might only upload data. Etc.
*
* Eventually I envision an intent-like approach, or additional methods, to specify preferences and constraints
* (e.g., "do what you can in a few seconds", or "do a full sync, no matter how long it takes"), but that'll come in time.
*
* A Synchronizer is a two-stage beast. It needs to support synchronization, of course; that
* needs a completely configured client, which can only be obtained from Ready. But it also
* needs to be able to do certain things beforehand:
*
* * Wipe its collections from the server (presumably via a delegate from the state machine).
* * Prepare to sync from scratch ("reset") in response to a changed set of keys, syncID, or node assignment.
* * Wipe local storage ("wipeClient").
*
* Those imply that some kind of 'Synchronizer' exists throughout the state machine. We *could*
* pickle instructions for eventual delivery next time one is made and synchronized
*/
public protocol Synchronizer {
init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason)
/**
* Return a reason if the current state of this synchronizer -- particularly prefs and scratchpad --
* prevent a routine sync from occurring.
*/
func reasonToNotSync(_: Sync15StorageClient) -> SyncNotStartedReason?
}
/**
* We sometimes wish to return something more nuanced than simple success or failure.
* For example, refusing to sync because the engine was disabled isn't success (nothing was transferred!)
* but it also isn't an error.
*
* To do this we model real failures -- something went wrong -- as failures in the Result, and
* everything else in this status enum. This will grow as we return more details from a sync to allow
* for batch scheduling, success-case backoff and so on.
*/
public enum SyncStatus {
case completed(SyncEngineStatsSession)
case notStarted(SyncNotStartedReason)
case partial(SyncEngineStatsSession)
public var description: String {
switch self {
case .completed:
return "Completed"
case let .notStarted(reason):
return "Not started: \(reason.description)"
case .partial:
return "Partial"
}
}
}
public typealias DeferredTimestamp = Deferred<Maybe<Timestamp>>
public typealias SyncResult = Deferred<Maybe<SyncStatus>>
public typealias EngineIdentifier = String
public typealias EngineStatus = (EngineIdentifier, SyncStatus)
public typealias EngineResults = [EngineStatus]
public typealias SyncOperationResult = (engineResults: Maybe<EngineResults>, stats: SyncOperationStatsSession?)
public enum SyncNotStartedReason {
case noAccount
case offline
case backoff(remainingSeconds: Int)
case engineRemotelyNotEnabled(collection: String)
case engineFormatOutdated(needs: Int)
case engineFormatTooNew(expected: Int) // This'll disappear eventually; we'll wipe the server and upload m/g.
case storageFormatOutdated(needs: Int)
case storageFormatTooNew(expected: Int) // This'll disappear eventually; we'll wipe the server and upload m/g.
case stateMachineNotReady // Because we're not done implementing.
case redLight
case unknown // Likely a programming error.
var telemetryId: String {
switch self {
case .noAccount:
return "sync.not_started.reason.no_account"
case .offline:
return "sync.not_started.reason.offline"
case .backoff(_):
return "sync.not_started.reason.backoff"
case .engineRemotelyNotEnabled(_):
return "sync.not_started.reason.remotely_not_enabled"
case .engineFormatOutdated(_):
return "sync.not_started.reason.format_outdated"
case .engineFormatTooNew(_): // This'll disappear eventually; we'll wipe the server and upload m/g.
return "sync.not_started.reason.format_too_new"
case .storageFormatOutdated(_):
return "sync.not_started.reason.storage_format_outdated"
case .storageFormatTooNew(_): // This'll disappear eventually; we'll wipe the server and upload m/g.
return "sync.not_started.reason.storage_format_too_new"
case .stateMachineNotReady: // Because we're not done implementing.
return "sync.not_started.reason.state_machine_not_ready"
case .redLight:
return "sync.not_started.reason.red_light"
case .unknown: // Likely a programming error
return "sync.not_started.reason.unknown"
}
}
var description: String {
switch self {
case .noAccount:
return "no account"
case let .backoff(remaining):
return "in backoff: \(remaining) seconds remaining"
default:
return "undescribed reason"
}
}
}
open class FatalError: SyncError {
let message: String
init(message: String) {
self.message = message
}
open var description: String {
return self.message
}
}
public protocol SingleCollectionSynchronizer {
func remoteHasChanges(_ info: InfoCollections) -> Bool
}
open class BaseCollectionSynchronizer {
let collection: String
let scratchpad: Scratchpad
let delegate: SyncDelegate
let basePrefs: Prefs
let prefs: Prefs
let why: SyncReason
var statsSession: SyncEngineStatsSession
static func prefsForCollection(_ collection: String, withBasePrefs basePrefs: Prefs) -> Prefs {
let branchName = "synchronizer." + collection + "."
return basePrefs.branch(branchName)
}
init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason, collection: String) {
self.scratchpad = scratchpad
self.delegate = delegate
self.collection = collection
self.basePrefs = basePrefs
self.prefs = BaseCollectionSynchronizer.prefsForCollection(collection, withBasePrefs: basePrefs)
self.statsSession = SyncEngineStatsSession(collection: collection)
self.why = why
log.info("Synchronizer configured with prefs '\(self.prefs.getBranchPrefix()).'")
}
var storageVersion: Int {
assert(false, "Override me!")
return 0
}
// Short-hand for returning .Complete status + recorded stats
var completedWithStats: SyncStatus {
return .completed(statsSession.end())
}
open func reasonToNotSync(_ client: Sync15StorageClient) -> SyncNotStartedReason? {
let now = Date.now()
if let until = client.backoff.isInBackoff(now) {
let remaining = (until - now) / 1000
return .backoff(remainingSeconds: Int(remaining))
}
if let metaGlobal = self.scratchpad.global?.value {
// There's no need to check the global storage format here; the state machine will already have
// done so.
if let engineMeta = metaGlobal.engines[collection] {
if engineMeta.version > self.storageVersion {
return .engineFormatOutdated(needs: engineMeta.version)
}
if engineMeta.version < self.storageVersion {
return .engineFormatTooNew(expected: engineMeta.version)
}
} else {
return .engineRemotelyNotEnabled(collection: self.collection)
}
} else {
// But a missing meta/global is a real problem.
return .stateMachineNotReady
}
// Success!
return nil
}
func encrypter<T>(_ encoder: RecordEncoder<T>) -> RecordEncrypter<T>? {
return self.scratchpad.keys?.value.encrypter(self.collection, encoder: encoder)
}
func collectionClient<T>(_ encoder: RecordEncoder<T>, storageClient: Sync15StorageClient) -> Sync15CollectionClient<T>? {
if let encrypter = self.encrypter(encoder) {
return storageClient.clientForCollection(self.collection, encrypter: encrypter)
}
return nil
}
}
/**
* Tracks a lastFetched timestamp, uses it to decide if there are any
* remote changes, and exposes a method to fast-forward after upload.
*/
open class TimestampedSingleCollectionSynchronizer: BaseCollectionSynchronizer, SingleCollectionSynchronizer {
var lastFetched: Timestamp {
set(value) {
self.prefs.setLong(value, forKey: "lastFetched")
}
get {
return self.prefs.unsignedLongForKey("lastFetched") ?? 0
}
}
func setTimestamp(_ timestamp: Timestamp) {
log.debug("Setting post-upload lastFetched to \(timestamp).")
self.lastFetched = timestamp
}
open func remoteHasChanges(_ info: InfoCollections) -> Bool {
return info.modified(self.collection) ?? 0 > self.lastFetched
}
}
extension BaseCollectionSynchronizer: ResettableSynchronizer {
public static func resetSynchronizerWithStorage(_ storage: ResettableSyncStorage, basePrefs: Prefs, collection: String) -> Success {
let synchronizerPrefs = BaseCollectionSynchronizer.prefsForCollection(collection, withBasePrefs: basePrefs)
synchronizerPrefs.removeObjectForKey("lastFetched")
// Not all synchronizers use a batching downloader, but it's
// convenient to just always reset it here.
return storage.resetClient()
>>> effect({ BatchingDownloader.resetDownloaderWithPrefs(synchronizerPrefs, collection: collection) })
}
}

View file

@ -0,0 +1,206 @@
/* 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 XCGLogger
import Deferred
import SwiftyJSON
private let log = Logger.syncLogger
let TabsStorageVersion = 1
open class TabsSynchronizer: TimestampedSingleCollectionSynchronizer, Synchronizer {
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "tabs")
}
override var storageVersion: Int {
return TabsStorageVersion
}
var tabsRecordLastUpload: Timestamp {
set(value) {
self.prefs.setLong(value, forKey: "lastTabsUpload")
}
get {
return self.prefs.unsignedLongForKey("lastTabsUpload") ?? 0
}
}
fileprivate func createOwnTabsRecord(_ tabs: [RemoteTab]) -> Record<TabsPayload> {
let guid = self.scratchpad.clientGUID
let tabsJSON = JSON([
"id": guid,
"clientName": self.scratchpad.clientName,
"tabs": tabs.flatMap { $0.toDictionary() }
])
if Logger.logPII {
log.verbose("Sending tabs JSON \(tabsJSON.stringValue() ?? "nil")")
}
let payload = TabsPayload(tabsJSON)
return Record(id: guid, payload: payload, ttl: ThreeWeeksInSeconds)
}
fileprivate func uploadOurTabs(_ localTabs: RemoteClientsAndTabs, toServer tabsClient: Sync15CollectionClient<TabsPayload>) -> Success {
// check to see if our tabs have changed or we're in a fresh start
let lastUploadTime: Timestamp? = (self.tabsRecordLastUpload == 0) ? nil : self.tabsRecordLastUpload
if let lastUploadTime = lastUploadTime,
lastUploadTime >= (Date.now() - (OneMinuteInMilliseconds)) {
log.debug("Not uploading tabs: already did so at \(lastUploadTime).")
return succeed()
}
return localTabs.getTabsForClientWithGUID(nil) >>== { tabs in
if let lastUploadTime = lastUploadTime {
// TODO: track this in memory so we don't have to hit the disk to figure out when our tabs have
// changed and need to be uploaded.
if tabs.every({ $0.lastUsed < lastUploadTime }) {
return succeed()
}
}
let tabsRecord = self.createOwnTabsRecord(tabs)
log.debug("Uploading our tabs: \(tabs.count).")
var uploadStats = SyncUploadStats()
uploadStats.sent += 1
// We explicitly don't send If-Unmodified-Since, because we always
// want our upload to succeed -- we own the record.
return tabsClient.put(tabsRecord, ifUnmodifiedSince: nil) >>== { resp in
if let ts = resp.metadata.lastModifiedMilliseconds {
// Protocol says this should always be present for success responses.
log.debug("Tabs record upload succeeded. New timestamp: \(ts).")
self.tabsRecordLastUpload = ts
} else {
uploadStats.sentFailed += 1
}
return succeed()
} >>== effect({ self.statsSession.recordUpload(stats: uploadStats) })
}
}
open func synchronizeLocalTabs(_ localTabs: RemoteClientsAndTabs, withServer storageClient: Sync15StorageClient, info: InfoCollections) -> SyncResult {
func onResponseReceived(_ response: StorageResponse<[Record<TabsPayload>]>) -> Success {
func afterWipe() -> Success {
var downloadStats = SyncDownloadStats()
let doInsert: (Record<TabsPayload>) -> Deferred<Maybe<(Int)>> = { record in
let remotes = record.payload.isValid() ? record.payload.remoteTabs : []
let ins = localTabs.insertOrUpdateTabsForClientGUID(record.id, tabs: remotes)
// Since tabs are all sent within a single record, we don't count number of tabs applied
// but number of records. In this case it's just one.
downloadStats.applied += 1
ins.upon() { res in
if let inserted = res.successValue {
if inserted != remotes.count {
log.warning("Only inserted \(inserted) tabs, not \(remotes.count). Malformed or missing client?")
}
downloadStats.applied += 1
} else {
downloadStats.failed += 1
}
}
return ins
}
let ourGUID = self.scratchpad.clientGUID
let records = response.value
let responseTimestamp = response.metadata.lastModifiedMilliseconds
log.debug("Got \(records.count) tab records.")
// We can only insert tabs for clients that we know locally, so
// first we fetch the list of IDs and intersect the two.
// TODO: there's a much more efficient way of doing this.
return localTabs.getClientGUIDs()
>>== { clientGUIDs in
let filtered = records.filter({ $0.id != ourGUID && clientGUIDs.contains($0.id) })
if filtered.count != records.count {
log.debug("Filtered \(records.count) records down to \(filtered.count).")
}
let allDone = all(filtered.map(doInsert))
return allDone.bind { (results) -> Success in
if let failure = results.find({ $0.isFailure }) {
return deferMaybe(failure.failureValue!)
}
self.lastFetched = responseTimestamp!
return succeed()
}
} >>== effect({ self.statsSession.downloadStats })
}
// If this is a fresh start, do a wipe.
if self.lastFetched == 0 {
log.info("Last fetch was 0. Wiping tabs.")
return localTabs.wipeRemoteTabs()
>>== afterWipe
}
return afterWipe()
}
if let reason = self.reasonToNotSync(storageClient) {
return deferMaybe(SyncStatus.notStarted(reason))
}
let keys = self.scratchpad.keys?.value
let encoder = RecordEncoder<TabsPayload>(decode: { TabsPayload($0) }, encode: { $0.json })
if let encrypter = keys?.encrypter(self.collection, encoder: encoder) {
let tabsClient = storageClient.clientForCollection(self.collection, encrypter: encrypter)
statsSession.start()
if !self.remoteHasChanges(info) {
// upload local tabs if they've changed or we're in a fresh start.
return uploadOurTabs(localTabs, toServer: tabsClient)
>>> { deferMaybe(self.completedWithStats) }
}
return tabsClient.getSince(self.lastFetched)
>>== onResponseReceived
>>> { self.uploadOurTabs(localTabs, toServer: tabsClient) }
>>> { deferMaybe(self.completedWithStats) }
}
log.error("Couldn't make tabs factory.")
return deferMaybe(FatalError(message: "Couldn't make tabs factory."))
}
/**
* This is a dedicated resetting interface that does both tabs and clients at the
* same time.
*/
open static func resetClientsAndTabsWithStorage(_ storage: ResettableSyncStorage, basePrefs: Prefs) -> Success {
let clientPrefs = BaseCollectionSynchronizer.prefsForCollection("clients", withBasePrefs: basePrefs)
let tabsPrefs = BaseCollectionSynchronizer.prefsForCollection("tabs", withBasePrefs: basePrefs)
clientPrefs.removeObjectForKey("lastFetched")
tabsPrefs.removeObjectForKey("lastFetched")
return storage.resetClient()
}
}
extension RemoteTab {
public func toDictionary() -> Dictionary<String, Any>? {
let tabHistory = history.flatMap { $0.absoluteString }
if tabHistory.isEmpty {
return nil
}
return [
"title": title,
"icon": icon?.absoluteString as Any? ?? NSNull(),
"urlHistory": tabHistory,
"lastUsed": millisecondsToDecimalSeconds(lastUsed)
]
}
}