Skip to content

Commit 24366ed

Browse files
authored
Merge pull request #9424 from nextcloud/i2h3/fix/remote-change-discovery
File Provider Remote Change Discovery Fix
2 parents 2a64578 + 25f065b commit 24366ed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+573
-1859
lines changed

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public extension FilesDatabaseManager {
4040
return nil
4141
}
4242

43-
// Deletes all metadatas related to the info of the directory provided
43+
/// Deletes all metadatas related to the info of the directory provided
4444
func deleteDirectoryAndSubdirectoriesMetadata(
4545
ocId: String
4646
) -> [SendableItemMetadata]? {
@@ -126,7 +126,8 @@ public extension FilesDatabaseManager {
126126
"""
127127
Moved childItem at: \(oldServerUrl)
128128
to: \(movedServerUrl)
129-
""")
129+
"""
130+
)
130131
}
131132
}
132133
} catch {

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ public final class FilesDatabaseManager: Sendable {
3535
let logger: FileProviderLogger
3636
let account: Account
3737

38-
var itemMetadatas: Results<RealmItemMetadata> { ncDatabase().objects(RealmItemMetadata.self) }
38+
var itemMetadatas: Results<RealmItemMetadata> {
39+
ncDatabase().objects(RealmItemMetadata.self)
40+
}
3941

4042
///
4143
/// Convenience initializer which defines a default configuration for Realm.
@@ -278,8 +280,8 @@ public final class FilesDatabaseManager: Sendable {
278280
return (returningNewMetadatas, returningUpdatedMetadatas, directoriesNeedingRename)
279281
}
280282

281-
// ONLY HANDLES UPDATES FOR IMMEDIATE CHILDREN
282-
// (in case of directory renames/moves, the changes are recursed down)
283+
/// ONLY HANDLES UPDATES FOR IMMEDIATE CHILDREN
284+
/// (in case of directory renames/moves, the changes are recursed down)
283285
public func depth1ReadUpdateItemMetadatas(
284286
account: String,
285287
serverUrl: String,
@@ -389,8 +391,8 @@ public final class FilesDatabaseManager: Sendable {
389391
}
390392
}
391393

392-
// If setting a downloading or uploading status, also modified the relevant boolean properties
393-
// of the item metadata object
394+
/// If setting a downloading or uploading status, also modified the relevant boolean properties
395+
/// of the item metadata object
394396
public func setStatusForItemMetadata(
395397
_ metadata: SendableItemMetadata, status: Status
396398
) -> SendableItemMetadata? {

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ extension Enumerator {
2929
return (metadatas, nil)
3030
}
3131

32-
// With paginated requests, you do not have a way to know what has changed remotely when
33-
// handling the result of an individual PROPFIND request. When handling a paginated read this
34-
// therefore only returns the acquired metadatas.
32+
/// With paginated requests, you do not have a way to know what has changed remotely when
33+
/// handling the result of an individual PROPFIND request. When handling a paginated read this
34+
/// therefore only returns the acquired metadatas.
3535
static func handleDepth1ReadFileOrFolder(
3636
serverUrl: String,
3737
account: Account,
@@ -92,19 +92,19 @@ extension Enumerator {
9292
)
9393
}
9494

95-
// READ THIS CAREFULLY.
96-
//
97-
// This method supports paginated and non-paginated reads. Handled by the pageSettings argument.
98-
// Paginated reads is used by enumerateItems, non-paginated reads is used by enumerateChanges.
99-
//
100-
// Paginated reads WILL NOT HANDLE REMOVAL OF REMOTELY DELETED ITEMS FROM THE LOCAL DATABASE.
101-
// Paginated reads WILL ONLY REPORT THE FILES DISCOVERED REMOTELY.
102-
// This means that if you decide to use this method to implement change enumeration, you will
103-
// have to collect the full results of all the pages before proceeding with discovering what
104-
// has changed relative to the state of the local database -- manually!
105-
//
106-
// Non-paginated reads will update the database with all of the discovered files and folders
107-
// that have been found to be created, updated, and deleted. No extra work required.
95+
/// READ THIS CAREFULLY.
96+
///
97+
/// This method supports paginated and non-paginated reads. Handled by the pageSettings argument.
98+
/// Paginated reads is used by enumerateItems, non-paginated reads is used by enumerateChanges.
99+
///
100+
/// Paginated reads WILL NOT HANDLE REMOVAL OF REMOTELY DELETED ITEMS FROM THE LOCAL DATABASE.
101+
/// Paginated reads WILL ONLY REPORT THE FILES DISCOVERED REMOTELY.
102+
/// This means that if you decide to use this method to implement change enumeration, you will
103+
/// have to collect the full results of all the pages before proceeding with discovering what
104+
/// has changed relative to the state of the local database -- manually!
105+
///
106+
/// Non-paginated reads will update the database with all of the discovered files and folders
107+
/// that have been found to be created, updated, and deleted. No extra work required.
108108
static func readServerUrl(
109109
_ serverUrl: String,
110110
pageSettings: (page: NSFileProviderPage?, index: Int, size: Int)? = nil,

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift

Lines changed: 148 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
5353
} else {
5454
logger.debug("Providing enumerator for item with identifier.", [.item: enumeratedItemIdentifier])
5555
enumeratedItemMetadata = dbManager.itemMetadata(
56-
enumeratedItemIdentifier)
56+
enumeratedItemIdentifier
57+
)
5758

5859
if let enumeratedItemMetadata {
5960
serverUrl = enumeratedItemMetadata.serverUrl + "/" + enumeratedItemMetadata.fileName
@@ -199,11 +200,9 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
199200
logger.debug("Enumerating changes (anchor: \(String(data: anchor.rawValue, encoding: .utf8) ?? "")).", [.url: serverUrl])
200201

201202
/*
202-
- query the server for updates since the passed-in sync anchor (TODO)
203+
If this is an enumerator for the working set, then:
203204

204-
If this is an enumerator for the working set:
205205
- note the changes in your local database
206-
207206
- inform the observer about item deletions and updates (modifications + insertions)
208207
- inform the observer when you have finished enumerating up to a subsequent sync anchor
209208
*/
@@ -221,19 +220,22 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
221220
return
222221
}
223222

224-
let pendingChanges = dbManager.pendingWorkingSetChanges(account: account, since: date)
223+
Task {
224+
await checkMaterializedItemsOnServer()
225+
let pendingLocalChanges = dbManager.pendingWorkingSetChanges(account: account, since: date)
225226

226-
completeChangesObserver(
227-
observer,
228-
anchor: currentAnchor,
229-
enumeratedItemIdentifier: enumeratedItemIdentifier,
230-
account: account,
231-
remoteInterface: remoteInterface,
232-
dbManager: dbManager,
233-
newMetadatas: [],
234-
updatedMetadatas: pendingChanges.updated,
235-
deletedMetadatas: pendingChanges.deleted
236-
)
227+
completeChangesObserver(
228+
observer,
229+
anchor: currentAnchor,
230+
enumeratedItemIdentifier: enumeratedItemIdentifier,
231+
account: account,
232+
remoteInterface: remoteInterface,
233+
dbManager: dbManager,
234+
newMetadatas: [],
235+
updatedMetadatas: pendingLocalChanges.updated,
236+
deletedMetadatas: pendingLocalChanges.deleted
237+
)
238+
}
237239

238240
return
239241
} else if enumeratedItemIdentifier == .trashContainer {
@@ -393,6 +395,131 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
393395

394396
// MARK: - Helper methods
395397

398+
private func checkMaterializedItemsOnServer() async {
399+
logger.debug("Checking materialized items for changes on the server...")
400+
401+
defer {
402+
logger.debug("Completed checking materialized items for changes on the server.")
403+
}
404+
405+
// Unlike when enumerating items we can't progressively enumerate items as we need to
406+
// wait to see which items are truly deleted and which have just been moved elsewhere.
407+
// Visited folders and downloaded files. Sort in terms of their remote URLs.
408+
// This way we ensure we visit parent folders before their children.
409+
let materializedItems = dbManager
410+
.materialisedItemMetadatas(account: account.ncKitAccount)
411+
.sorted { $0.remotePath().count < $1.remotePath().count }
412+
413+
var allNewMetadatas = [SendableItemMetadata]()
414+
var allUpdatedMetadatas = [SendableItemMetadata]()
415+
var allDeletedMetadatas = [SendableItemMetadata]()
416+
var examinedItemIds = Set<String>()
417+
418+
for materializedItem in materializedItems where !examinedItemIds.contains(materializedItem.ocId) {
419+
guard isLockFileName(materializedItem.fileName) == false else {
420+
// Skip server requests for locally created lock files.
421+
// They are not synchronized to the server for real.
422+
// Thus they can be expected not to be found there.
423+
// That would also cause their local deletion due to synchronization logic.
424+
logger.debug("Skipping materialized item in working set check because the name hints a lock file.", [.item: materializedItem, .name: materializedItem.name])
425+
continue
426+
}
427+
428+
let itemRemoteUrl = materializedItem.remotePath()
429+
430+
let (metadatas, newMetadatas, updatedMetadatas, deletedMetadatas, _, readError) = await Enumerator.readServerUrl(itemRemoteUrl, account: account, remoteInterface: remoteInterface, dbManager: dbManager, depth: materializedItem.directory ? .targetAndDirectChildren : .target, log: logger.log)
431+
432+
if readError?.errorCode == 404 {
433+
allDeletedMetadatas.append(materializedItem)
434+
examinedItemIds.insert(materializedItem.ocId)
435+
436+
materializedItems.filter {
437+
$0.serverUrl.hasPrefix(itemRemoteUrl)
438+
}.forEach {
439+
allDeletedMetadatas.append($0)
440+
examinedItemIds.insert($0.ocId)
441+
}
442+
} else if let readError, readError != .success {
443+
logger.error("Finished remote change enumeration of materialized items with error.", [.error: readError])
444+
return
445+
} else {
446+
allDeletedMetadatas += deletedMetadatas ?? []
447+
allUpdatedMetadatas += updatedMetadatas ?? []
448+
allNewMetadatas += newMetadatas ?? []
449+
450+
// Just because we have read child directories metadata doesn't mean we need to in turn scan their children. This is not the case for files.
451+
var examinedChildFilesAndDeletedItems = Set<String>()
452+
453+
if let metadatas, let target = metadatas.first {
454+
examinedItemIds.insert(target.ocId)
455+
456+
if metadatas.count > 1 {
457+
examinedChildFilesAndDeletedItems.formUnion(metadatas[1...].filter { !$0.directory }.map(\.ocId))
458+
}
459+
460+
// If the target is not in the updated metadatas then neither it, nor any of its kids have changed. So skip examining all of them.
461+
if !allUpdatedMetadatas.contains(where: { $0.ocId == target.ocId }) {
462+
logger.debug("Target has not changed. Skipping children.", [.url: itemRemoteUrl])
463+
let materialisedChildren = materializedItems.filter { $0.serverUrl.hasPrefix(itemRemoteUrl) }.map(\.ocId)
464+
examinedChildFilesAndDeletedItems.formUnion(materialisedChildren)
465+
}
466+
467+
// OPTIMIZATION: For any child directories returned in this enumeration, if they haven't changed (etag matches database), mark them as examined so we don't enumerate them separately later.
468+
if metadatas.count > 1 {
469+
let childDirectories = metadatas[1...].filter(\.directory)
470+
471+
for childDir in childDirectories {
472+
// Check if this directory is in our materialized items list
473+
if let localItem = materializedItems.first(where: { $0.ocId == childDir.ocId }), localItem.etag == childDir.etag {
474+
// Directory hasn't changed, mark as examined to skip separate enumeration.
475+
logger.debug("Child directory etag unchanged, marking as examined.", [.name: childDir.fileName, .eTag: childDir.etag])
476+
examinedChildFilesAndDeletedItems.insert(childDir.ocId)
477+
478+
// Also mark any materialized children of this directory as examined.
479+
let grandChildren = materializedItems.filter {
480+
$0.serverUrl.hasPrefix(localItem.remotePath())
481+
}
482+
483+
examinedChildFilesAndDeletedItems.formUnion(grandChildren.map(\.ocId))
484+
}
485+
}
486+
}
487+
488+
if let deletedMetadataOcIds = deletedMetadatas?.map(\.ocId) {
489+
examinedChildFilesAndDeletedItems.formUnion(deletedMetadataOcIds)
490+
}
491+
}
492+
493+
examinedItemIds.formUnion(examinedChildFilesAndDeletedItems)
494+
}
495+
}
496+
497+
// Run a check to ensure files deleted in one location are not updated in another (e.g. when moved).
498+
// The recursive scan provides us with updated/deleted metadatas only on a folder by folder basis; so we need to check we are not simultaneously marking a moved file as deleted and updated.
499+
var checkedDeletedMetadatas = allDeletedMetadatas
500+
501+
for updatedMetadata in allUpdatedMetadatas {
502+
guard let matchingDeletedMetadataIdx = checkedDeletedMetadatas.firstIndex(where: { $0.ocId == updatedMetadata.ocId }) else {
503+
continue
504+
}
505+
506+
checkedDeletedMetadatas.remove(at: matchingDeletedMetadataIdx)
507+
}
508+
509+
allDeletedMetadatas = checkedDeletedMetadatas
510+
511+
for deletedMetadata in allDeletedMetadatas {
512+
var deleteMarked = deletedMetadata
513+
deleteMarked.deleted = true
514+
deleteMarked.syncTime = Date()
515+
dbManager.addItemMetadata(deleteMarked)
516+
}
517+
518+
if allUpdatedMetadatas.isEmpty, allDeletedMetadatas.isEmpty {
519+
logger.info("No remote changes found in materialized items.")
520+
}
521+
}
522+
396523
static func fileProviderPageforNumPage(_: Int) -> NSFileProviderPage? {
397524
nil
398525
// TODO: Handle paging properly
@@ -416,9 +543,7 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
416543
logger.info("Did enumerate \(items.count) items. Next page is nil: \(nextPage == nil)")
417544

418545
if let nextPage, let nextPageData = try? JSONEncoder().encode(nextPage) {
419-
logger.info(
420-
"Next page: \(String(data: nextPageData, encoding: .utf8) ?? "?")"
421-
)
546+
logger.info("Next page: \(String(data: nextPageData, encoding: .utf8) ?? "?")")
422547
observer.finishEnumerating(upTo: NSFileProviderPage(nextPageData))
423548
} else {
424549
observer.finishEnumerating(upTo: nil)
@@ -467,11 +592,7 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
467592
guard newMetadatas != nil || updatedMetadatas != nil || deletedMetadatas != nil else {
468593
logger.error("Received invalid newMetadatas, updatedMetadatas or deletedMetadatas. Finished enumeration of changes with error.")
469594

470-
observer.finishEnumeratingWithError(
471-
NSError.fileProviderErrorForNonExistentItem(
472-
withIdentifier: enumeratedItemIdentifier
473-
)
474-
)
595+
observer.finishEnumeratingWithError(NSError.fileProviderErrorForNonExistentItem(withIdentifier: enumeratedItemIdentifier))
475596

476597
return
477598
}
@@ -492,17 +613,15 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable {
492613
allDeletedMetadatas = deletedMetadatas
493614
}
494615

495-
let allFpItemDeletionsIdentifiers = Array(
496-
allDeletedMetadatas.map { NSFileProviderItemIdentifier($0.ocId) })
616+
let allFpItemDeletionsIdentifiers = Array(allDeletedMetadatas.map { NSFileProviderItemIdentifier($0.ocId) })
617+
497618
if !allFpItemDeletionsIdentifiers.isEmpty {
498619
observer.didDeleteItems(withIdentifiers: allFpItemDeletionsIdentifiers)
499620
}
500621

501622
Task { [allUpdatedMetadatas, allDeletedMetadatas] in
502623
do {
503-
let updatedItems = try await allUpdatedMetadatas.toFileProviderItems(
504-
account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: self.logger.log
505-
)
624+
let updatedItems = try await allUpdatedMetadatas.toFileProviderItems(account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: self.logger.log)
506625

507626
Task { @MainActor in
508627
if !updatedItems.isEmpty {

0 commit comments

Comments
 (0)