Skip to content

Commit 5fa897c

Browse files
authored
Merge pull request #10466 from nextcloud/i2h3/fix/on2-enumeration-problem
Eliminate O(N²) Realm work in large-folder enumeration
2 parents f402d92 + 180d67a commit 5fa897c

13 files changed

Lines changed: 295 additions & 27 deletions

File tree

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public final class FilesDatabaseManager: Sendable {
3131
)
3232
}
3333

34-
private static let schemaVersion = SchemaVersion.addedCanonicalPathKeysToRealmItemMetadata
34+
private static let schemaVersion = SchemaVersion.addedNormalizedFileNameIndexToRealmItemMetadata
3535
let logger: FileProviderLogger
3636
let account: Account
3737

@@ -269,14 +269,18 @@ public final class FilesDatabaseManager: Sendable {
269269
existingMetadatas: Results<RealmItemMetadata>,
270270
updatedMetadatas: [SendableItemMetadata]
271271
) -> [RealmItemMetadata] {
272+
// O(1) membership test instead of a per-existing linear scan of `updatedMetadatas`. The previous
273+
// `updatedMetadatas.contains(where:)` made this loop O(existing × updated) — with the linear scan
274+
// in `processItemMetadatasToUpdate` it was the dominant cost of a large non-paginated depth-1
275+
// write (measured ≈19 min, never completing, for a 6982-item flat folder; no index can fix an
276+
// in-memory scan). `existingMetadata` is already the managed row from the caller's `database`
277+
// handle and is value-copied before the write, so it is used directly — this also removes the
278+
// per-item `itemMetadatas` (`ncDatabase()`) re-open the old `.where{}.first` fetch incurred.
279+
let updatedOcIds = Set(updatedMetadatas.map(\.ocId))
272280
var deletedMetadatas: [RealmItemMetadata] = []
273281

274-
for existingMetadata in existingMetadatas {
275-
guard !updatedMetadatas.contains(where: { $0.ocId == existingMetadata.ocId }),
276-
let metadataToDelete = itemMetadatas.where({ $0.ocId == existingMetadata.ocId }).first
277-
else { continue }
278-
279-
deletedMetadatas.append(metadataToDelete)
282+
for existingMetadata in existingMetadatas where !updatedOcIds.contains(existingMetadata.ocId) {
283+
deletedMetadatas.append(existingMetadata)
280284

281285
logger.debug("Deleting item metadata during update.", [.item: existingMetadata.ocId])
282286
}
@@ -289,8 +293,22 @@ public final class FilesDatabaseManager: Sendable {
289293
var returningUpdatedMetadatas: [SendableItemMetadata] = []
290294
var directoriesNeedingRename: [SendableItemMetadata] = []
291295

296+
// O(1) ocId lookup instead of `existingMetadatas.first(where:)` — the old per-item linear scan of
297+
// a Realm `Results` was O(updated × existing), the other half of the measured O(N²) large-folder
298+
// write. Keyed once up front (first occurrence wins, matching `.first(where:)`).
299+
var existingByOcId: [String: RealmItemMetadata] = [:]
300+
existingByOcId.reserveCapacity(existingMetadatas.count)
301+
for existingMetadata in existingMetadatas where existingByOcId[existingMetadata.ocId] == nil {
302+
existingByOcId[existingMetadata.ocId] = existingMetadata
303+
}
304+
305+
// `inheritedKeepDownloaded` depends on the item only through (account, parent serverUrl); every
306+
// child of a folder shares one serverUrl, so cache per serverUrl to collapse N parent lookups
307+
// (each a DB query) to one per distinct parent.
308+
var inheritedKeepDownloadedByServerUrl: [String: Bool] = [:]
309+
292310
for var updatedMetadata in updatedMetadatas {
293-
if let existingMetadata = existingMetadatas.first(where: { $0.ocId == updatedMetadata.ocId }) {
311+
if let existingMetadata = existingByOcId[updatedMetadata.ocId] {
294312
if existingMetadata.status == Status.normal.rawValue, !existingMetadata.isInSameDatabaseStoreableRemoteState(updatedMetadata) {
295313
let pathChanged = !updatedMetadata.hasSameLocation(as: existingMetadata)
296314

@@ -325,7 +343,13 @@ public final class FilesDatabaseManager: Sendable {
325343

326344
} else { // This is a new metadata
327345
// Inherit the parent's "Always keep downloaded" flag so a file surfacing here via remote enumeration acquires the same pin as its already-pinned siblings (#10054).
328-
updatedMetadata.keepDownloaded = inheritedKeepDownloaded(for: updatedMetadata)
346+
if let cached = inheritedKeepDownloadedByServerUrl[updatedMetadata.serverUrl] {
347+
updatedMetadata.keepDownloaded = cached
348+
} else {
349+
let inherited = inheritedKeepDownloaded(for: updatedMetadata)
350+
inheritedKeepDownloadedByServerUrl[updatedMetadata.serverUrl] = inherited
351+
updatedMetadata.keepDownloaded = inherited
352+
}
329353

330354
returningNewMetadatas.append(updatedMetadata)
331355

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ enum SchemaVersion: UInt64 {
1010
case addedLockTokenPropertyToRealmItemMetadata = 201
1111
case addedIsLockFileOfLocalOriginToRealmItemMetadata = 202
1212
case addedCanonicalPathKeysToRealmItemMetadata = 203
13+
case addedNormalizedFileNameIndexToRealmItemMetadata = 204
1314
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ extension Enumerator {
6767
logger.debug("Enumerating page: \(String(data: page.rawValue, encoding: .utf8) ?? "")", [.account: account.ncKitAccount, .url: serverUrl])
6868

6969
Task {
70+
// Bound the synchronous page work (cursor decode + capabilities + network + convert +
71+
// persist) for a trace, and record its wall-clock for the JSONL fallback. The `defer` ends
72+
// the interval on every exit — including the error returns below — so no interval dangles.
73+
// The trailing observer conversion/report runs in a detached Task and is captured by the
74+
// separate `ToFileProviderItems` / `ObserverReport` signposts.
75+
let signposter = EnumerationSignposter.signposter
76+
let pageWorkState = signposter.beginInterval(
77+
"EnumeratePageWork",
78+
id: signposter.makeSignpostID(),
79+
"serverUrl=\(self.serverUrl, privacy: .public)"
80+
)
81+
defer { signposter.endInterval("EnumeratePageWork", pageWorkState) }
82+
let pageWorkClock = ContinuousClock()
83+
let pageWorkStart = pageWorkClock.now
84+
7085
let cursor = paginationCursor(from: page)
7186

7287
// Check server version to determine if pagination should be enabled.
@@ -149,6 +164,12 @@ extension Enumerator {
149164
rawNextPage = nil
150165
}
151166

167+
let pageWorkElapsed = pageWorkClock.now - pageWorkStart
168+
logger.performance(
169+
"PERF EnumeratePageWork items=\(items.count) page_work_s=\(pageWorkElapsed.fpSeconds) hasNextPage=\(rawNextPage != nil)",
170+
[.url: self.serverUrl]
171+
)
172+
152173
completeEnumerationObserver(observer, nextPage: rawNextPage, itemMetadatas: items)
153174
}
154175
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,30 @@ extension Enumerator {
2424
handleInvalidParent: Bool = true
2525
) {
2626
Task {
27+
let signposter = EnumerationSignposter.signposter
28+
let toItemsState = signposter.beginInterval(
29+
"ToFileProviderItems",
30+
id: signposter.makeSignpostID(),
31+
"count=\(itemMetadatas.count)"
32+
)
2733
do {
2834
let items = try await itemMetadatas.toFileProviderItems(
2935
account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: self.logger.log
3036
)
37+
signposter.endInterval("ToFileProviderItems", toItemsState)
3138

3239
Task { @MainActor in
40+
// Begin/end stay in this MainActor scope; both observer calls are synchronous.
41+
let reportState = signposter.beginInterval(
42+
"ObserverReport", id: signposter.makeSignpostID(), "items=\(items.count)"
43+
)
3344
observer.didEnumerate(items)
3445
logger.info("Did enumerate \(items.count) items. Next page is nil: \(nextPage == nil)")
3546
observer.finishEnumerating(upTo: nextPage)
47+
signposter.endInterval("ObserverReport", reportState)
3648
}
3749
} catch let error as NSError { // This error can only mean a missing parent item identifier
50+
signposter.endInterval("ToFileProviderItems", toItemsState)
3851
guard handleInvalidParent else {
3952
logger.info("Not handling invalid parent in enumeration.")
4053
observer.finishEnumeratingWithError(error)

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

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import NextcloudKit
66

77
extension Enumerator {
88
static func handlePagedReadResults(
9-
files: [NKFile], pageIndex: Int, dbManager: FilesDatabaseManager
9+
files: [NKFile], pageIndex: Int, dbManager: FilesDatabaseManager, log: any FileProviderLogging
1010
) -> (metadatas: [SendableItemMetadata]?, error: NKError?) {
1111
// First PROPFIND contains the target item, but we do not want to report this in the
1212
// retrieved metadatas (the enumeration observers don't expect you to enumerate the
@@ -40,10 +40,44 @@ extension Enumerator {
4040
// `keepDownloaded == false` for items that are pinned in the
4141
// database, leaving the OS view (`isKeepDownloaded`, `contentPolicy`)
4242
// out of sync with the local truth.
43-
let metadatas = files[startIndex...].map { file -> SendableItemMetadata in
44-
dbManager.addItemMetadataPreservingLocalState(file.toItemMetadata())
43+
//
44+
// Conversion and persistence are timed separately (convAccum / dbAccum) so the JSONL PERF
45+
// line splits CPU spent building metadata from CPU spent in Realm. Today each item opens its
46+
// own write transaction inside `addItemMetadataPreservingLocalState`; `db_items_per_s` is the
47+
// throughput number to watch, and the enclosing `ConvertAndPersistPage` signpost bounds the
48+
// whole page for Instruments. (Phase 2 batches these into one transaction per page.)
49+
let signposter = EnumerationSignposter.signposter
50+
let convAndPersistState = signposter.beginInterval(
51+
"ConvertAndPersistPage",
52+
id: signposter.makeSignpostID(),
53+
"pageIndex=\(pageIndex) files=\(files.count)"
54+
)
55+
56+
let clock = ContinuousClock()
57+
var convAccum: Duration = .zero
58+
var dbAccum: Duration = .zero
59+
var metadatas: [SendableItemMetadata] = []
60+
metadatas.reserveCapacity(max(0, files.count - startIndex))
61+
62+
for file in files[startIndex...] {
63+
let convStart = clock.now
64+
let itemMetadata = file.toItemMetadata()
65+
convAccum += clock.now - convStart
66+
67+
let dbStart = clock.now
68+
metadatas.append(dbManager.addItemMetadataPreservingLocalState(itemMetadata))
69+
dbAccum += clock.now - dbStart
4570
}
4671

72+
signposter.endInterval("ConvertAndPersistPage", convAndPersistState, "items=\(metadatas.count)")
73+
74+
let itemCount = metadatas.count
75+
let dbSeconds = dbAccum.fpSeconds
76+
let dbRate = dbSeconds > 0 ? Double(itemCount) / dbSeconds : 0
77+
FileProviderLogger(category: "Enumerator", log: log).performance(
78+
"PERF ConvertAndPersistPage pageIndex=\(pageIndex) items=\(itemCount) conv_s=\(convAccum.fpSeconds) db_s=\(dbSeconds) db_items_per_s=\(dbRate)"
79+
)
80+
4781
return (metadatas, nil)
4882
}
4983

@@ -68,11 +102,23 @@ extension Enumerator {
68102

69103
if let pageIndex {
70104
let (metadatas, error) =
71-
handlePagedReadResults(files: files, pageIndex: pageIndex, dbManager: dbManager)
105+
handlePagedReadResults(files: files, pageIndex: pageIndex, dbManager: dbManager, log: log)
72106
return (metadatas, nil, error)
73107
}
74108

75-
guard var (directory, _, files) = await files.toSendableDirectoryMetadata(account: account, directoryToRead: serverUrl) else {
109+
// Non-paginated path (older servers / change enumeration): conversion is parallelized and the
110+
// persist is a single batched transaction. Signpost each so its cost is comparable, in a trace,
111+
// against the paginated path's per-item behavior.
112+
let signposter = EnumerationSignposter.signposter
113+
let convDirState = signposter.beginInterval(
114+
"ConvertDirectoryMetadata",
115+
id: signposter.makeSignpostID(),
116+
"serverUrl=\(serverUrl, privacy: .public) files=\(files.count)"
117+
)
118+
let convertedDirectory = await files.toSendableDirectoryMetadata(account: account, directoryToRead: serverUrl)
119+
signposter.endInterval("ConvertDirectoryMetadata", convDirState)
120+
121+
guard var (directory, _, files) = convertedDirectory else {
76122
logger.error("Failed to convert array of NKFile to directory and files metadata objects!")
77123
return (nil, nil, .invalidData)
78124
}
@@ -92,12 +138,31 @@ extension Enumerator {
92138

93139
files.insert(directory, at: 0)
94140

141+
let batchClock = ContinuousClock()
142+
let batchStart = batchClock.now
143+
let batchWriteState = signposter.beginInterval(
144+
"Depth1BatchWrite",
145+
id: signposter.makeSignpostID(),
146+
"serverUrl=\(serverUrl, privacy: .public) items=\(files.count)"
147+
)
95148
let changes = dbManager.depth1ReadUpdateItemMetadatas(
96149
account: account.ncKitAccount,
97150
serverUrl: serverUrl,
98151
updatedMetadatas: files,
99152
keepExistingDownloadState: true
100153
)
154+
signposter.endInterval("Depth1BatchWrite", batchWriteState)
155+
156+
// The non-paginated depth-1 write (change / working-set full-folder re-read) is the measured
157+
// enumeration bottleneck: its per-item logical-dedup scans are O(N²) over a flat folder. Log its
158+
// wall-clock + items/sec so the effect of the normalizedFileName index is visible in the JSONL
159+
// (the `Depth1BatchWrite` signpost shows the same in Instruments).
160+
let batchElapsed = batchClock.now - batchStart
161+
let batchRate = batchElapsed.fpSeconds > 0 ? Double(files.count) / batchElapsed.fpSeconds : 0
162+
logger.performance(
163+
"PERF Depth1BatchWrite items=\(files.count) write_s=\(batchElapsed.fpSeconds) items_per_s=\(batchRate)",
164+
[.url: serverUrl]
165+
)
101166

102167
return (files, changes, nil)
103168
}
@@ -140,6 +205,21 @@ extension Enumerator {
140205
.init()
141206
}
142207

208+
// Signpost + wall-clock the network read in isolation so a trace (or the JSONL PERF line) can
209+
// attribute enumeration latency to the paginated PROPFIND (server-bound) versus the local
210+
// conversion + Realm persistence (CPU-bound). begin/end stay in this one function scope so the
211+
// non-Sendable interval state never crosses the `await`'s potential thread hop.
212+
let pageIndexForLog = pageSettings?.index ?? 0
213+
let signposter = EnumerationSignposter.signposter
214+
let propfindSignpostID = signposter.makeSignpostID()
215+
let propfindState = signposter.beginInterval(
216+
"PROPFIND",
217+
id: propfindSignpostID,
218+
"serverUrl=\(serverUrl, privacy: .public) index=\(pageIndexForLog) depth=\(depth.rawValue, privacy: .public)"
219+
)
220+
let networkClock = ContinuousClock()
221+
let networkStart = networkClock.now
222+
143223
let (_, files, data, error) = await remoteInterface.enumerate(
144224
remotePath: serverUrl,
145225
depth: depth,
@@ -159,6 +239,13 @@ extension Enumerator {
159239
}
160240
)
161241

242+
let networkElapsed = networkClock.now - networkStart
243+
signposter.endInterval("PROPFIND", propfindState, "files=\(files.count)")
244+
logger.performance(
245+
"PERF PROPFIND index=\(pageIndexForLog) net_s=\(networkElapsed.fpSeconds) files=\(files.count) depth=\(depth.rawValue)",
246+
[.url: serverUrl]
247+
)
248+
162249
guard error == .success else {
163250
logger.error("Read of URL did fail.", [.error: error, .url: serverUrl])
164251
return RemoteReadResult(error: error)
@@ -259,7 +346,7 @@ extension Enumerator {
259346
)
260347
} else if let pageIndex = pageSettings?.index {
261348
let (metadatas, error) = handlePagedReadResults(
262-
files: files, pageIndex: pageIndex, dbManager: dbManager
349+
files: files, pageIndex: pageIndex, dbManager: dbManager, log: log
263350
)
264351
return RemoteReadResult(metadatas: metadatas, nextPage: nextPage, error: error)
265352
} else {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: LGPL-3.0-or-later
3+
4+
import Foundation
5+
import os
6+
7+
///
8+
/// Process-global `OSSignposter` for the enumeration hot path.
9+
///
10+
/// The enumeration + persistence work runs across `static func`s that receive no `self`, so a
11+
/// shared signposter is the least invasive way to instrument them without threading a handle
12+
/// through every signature. `OSSignposter` (and the `OSSignpostID`s it vends) are `Sendable`, so a
13+
/// global `let` is safe under Swift 6 strict concurrency and usable from any isolation domain.
14+
///
15+
/// The subsystem mirrors ``FileProviderLogger`` (the extension bundle identifier) so signposts and
16+
/// log messages share the same subsystem in Instruments and `log stream`. The `"PointsOfInterest"`
17+
/// category makes intervals appear in the built-in *Points of Interest* instrument with no extra
18+
/// configuration.
19+
///
20+
/// Signposts are near-zero cost when no Instruments trace (or `log stream --signpost`) is attached:
21+
/// `signposter.signpostsEnabled` is `false` and the interval calls become cheap branches, and the
22+
/// message interpolations are only evaluated when a consumer is present. They are therefore safe to
23+
/// ship enabled — no `#if DEBUG` gate.
24+
///
25+
enum EnumerationSignposter {
26+
static let signposter = OSSignposter(
27+
subsystem: Bundle.main.bundleIdentifier ?? "",
28+
category: "PointsOfInterest"
29+
)
30+
}
31+
32+
extension Duration {
33+
///
34+
/// This duration expressed as fractional seconds, for human-readable performance logging.
35+
///
36+
/// `components` yields whole seconds plus attoseconds; recombine them into a `Double`. Precision
37+
/// loss at the attosecond scale is irrelevant for wall-clock timings measured in milliseconds.
38+
///
39+
var fpSeconds: Double {
40+
let parts = components
41+
return Double(parts.seconds) + Double(parts.attoseconds) / 1e18
42+
}
43+
}

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ public actor FileProviderLog: FileProviderLogging {
100100
///
101101
nonisolated(unsafe) var debugLoggingObservation: NSKeyValueObservation?
102102

103+
///
104+
/// Whether performance-timing messages are written; see ``FileProviderLogging/performanceLoggingEnabled``.
105+
///
106+
/// Read live from `UserDefaults.standard` (key `"performanceLoggingEnabled"`) on each access rather than cached
107+
/// via KVO like ``debugLoggingEnabled``: performance lines are emitted at most a couple of times per enumerated
108+
/// page, so a live read is cheap and picks up `defaults write` immediately without any observation machinery.
109+
/// Defaults to `false` when the key is unset or not a boolean, in both DEBUG and release builds.
110+
///
111+
public var performanceLoggingEnabled: Bool {
112+
(UserDefaults.standard.object(forKey: "performanceLoggingEnabled") as? NSNumber)?.boolValue ?? false
113+
}
114+
103115
///
104116
/// Initialize a new log file abstraction.
105117
///

0 commit comments

Comments
 (0)