Dactyloidae/mobile/ios/Sync/Synchronizers/Bookmarks/BookmarksSynchronizer.swift
2026-06-26 21:04:09 -07:00

511 lines
26 KiB
Swift

/* 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
}
}
}
}
}