Skip to content

Indexed Logical Address Lookups - #10750

Open
juliusvaart wants to merge 1 commit into
nextcloud:masterfrom
juliusvaart:macos/vf/indexed-lookups
Open

Indexed Logical Address Lookups#10750
juliusvaart wants to merge 1 commit into
nextcloud:masterfrom
juliusvaart:macos/vf/indexed-lookups

Conversation

@juliusvaart

@juliusvaart juliusvaart commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

RealmItemMetadata.hasLocation compared the two indexed normalized keys, but also accepted a row whose keys were empty by falling back to the raw serverUrl and fileName columns. Those columns are not indexed, and a disjunction that reaches them cannot be answered from an index, so Realm scanned the whole table instead -- once per row being ingested. The index that was added to remove that scan could never be used.

The fallback existed for rows written before the normalized keys, and there are none: the addedCanonicalPathKeys migration backfilled them, and updateLocation is the only way either raw column is written since. Compare on the normalized keys alone, and repair any row that lacks them when the database is opened, so the assumption is enforced rather than trusted. Enforcing it matters because the failure is silent -- a row with empty keys does not merely take longer to find, it stops matching, so its duplicates are never evicted and it is never found by path.

Ingesting 2000 rows into a 10000-row database: 1.61 s to 0.46 s. The per-directory cost also stops growing with the database, which is what made this worse the longer the client ran: across a 100x larger database it went from 1.78x to 1.12x, and at 20000 rows the ingest is 31.2 ms to 14.4 ms.

Two test fixtures built rows by assigning the raw columns directly, which no production path does; they now go through updateLocation like everything else.

Assisted-by: Claude Code:claude-opus-5

Checklist

AI (if applicable)

@i2h3

i2h3 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

@juliusvaart Please ensure this pull request is based on master (unless it really is just meant to be integrated into the specific major release branch and not any future releases as well) and opened to be integrated into master.

@juliusvaart
juliusvaart force-pushed the macos/vf/indexed-lookups branch from 50e9022 to 8cb8566 Compare September 7, 2026 13:39
@juliusvaart juliusvaart closed this Sep 7, 2026
@juliusvaart juliusvaart reopened this Sep 7, 2026
@juliusvaart
juliusvaart changed the base branch from stable-34.0 to master September 7, 2026 13:51
@juliusvaart

Copy link
Copy Markdown
Contributor Author

Now based on master

`RealmItemMetadata.hasLocation` compared the two indexed normalized keys, but
also accepted a row whose keys were empty by falling back to the raw serverUrl
and fileName columns. Those columns are not indexed, and a disjunction that
reaches them cannot be answered from an index, so Realm scanned the whole table
instead -- once per row being ingested. The index that was added to remove that
scan could never be used.

The fallback existed for rows written before the normalized keys, and there are
none: the addedCanonicalPathKeys migration backfilled them, and updateLocation
is the only way either raw column is written since. Compare on the normalized
keys alone, and repair any row that lacks them when the database is opened, so
the assumption is enforced rather than trusted. Enforcing it matters because the
failure is silent -- a row with empty keys does not merely take longer to find,
it stops matching, so its duplicates are never evicted and it is never found by
path.

Ingesting 2000 rows into a 10000-row database: 1.61 s to 0.46 s. The
per-directory cost also stops growing with the database, which is what made this
worse the longer the client ran: across a 100x larger database it went from
1.78x to 1.12x, and at 20000 rows the ingest is 31.2 ms to 14.4 ms.

Two test fixtures built rows by assigning the raw columns directly, which no
production path does; they now go through updateLocation like everything else.

Signed-off-by: Julius van der Vaart <julius@vanderva.art>
Assisted-by: Claude Code:claude-opus-5
@juliusvaart
juliusvaart force-pushed the macos/vf/indexed-lookups branch from 8cb8566 to 6c0b687 Compare September 8, 2026 06:49
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Artifact containing the AppImage: nextcloud-appimage-pr-10750.zip

Digest: sha256:5fb65feaf9bdc5e313760f84b2fed78d7d13ee5c942c8b143b37a044323163ae

To test this change/fix you can download the above artifact file, unzip it, and run it.

Please make sure to quit your existing Nextcloud app and backup your data.

@i2h3 i2h3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the branch locally. The change is correct and I would merge it — the diagnosis holds and so does the safety argument, and I checked both against primary sources rather than taking the description's word for it. One substantive suggestion that I think leaves most of the remaining win on the table, plus some documentation fallout.

What I verified

The diagnosis is right. In realm-core at the revision this package pins (b4192c46, v20.1.5), OrNode (query_engine.hpp:2113) overrides neither has_search_index() nor index_based_keys(), so the ParentNode base returns false / nullptr. An OrNode can therefore never be index-driven, exactly as the description claims. The old predicate wrapped each key in a disjunction reaching an unindexed column, so neither index was reachable.

The safety argument is right. Schema 203 (14a48a68d3) and updateLocation (9bfccd9a36) both landed in #10443 and reached master in the same merge, 77d5d00ec8. No released build ever carried schema >= 203 without updateLocation, so no shipped client can hold a row the migration missed. I also swept Sources/ for writes to the raw columns: updateLocation and RealmItemMetadata(value:) (whose first statement calls it) are the only paths, and both add() calls on already-live objects are immediately preceded by updateLocation.

Build and tests. swift build clean. Full package suite green: 354 XCTest (1 skipped) plus 59 swift-testing, 0 failures. The four new tests pass.

The one thing I would change before merging

See the inline comment on hasLocation. Short version: with the fallback gone, both conjuncts are now indexed-equality nodes, and realm-core has no selectivity estimate at plan time. ParentNode::init sets m_dD = 100.0 unconditionally (query_engine.hpp:152) and StringNodeEqualBase::init sets m_dT = 0.0 for any indexed column (query_engine.cpp:274), so both nodes score exactly 8 * 64 / 100 + 0 = 5.12. Query::find_best_node selects with std::min_element (query.cpp:1166), which returns the first minimum, so the tie is broken by source order. As written, Realm drives the query off normalizedServerUrl — the whole sibling set of the parent folder — instead of the near-unique normalizedFileName. Swapping the two operands is a one-line change.

This also inverts the rationale comment on normalizedFileName at RealmItemMetadata.swift:57-62, which says normalizedServerUrl's index is "useless for a large flat folder ... whereas the (near-unique) file name lets Realm's planner drive off this index instead". That is the intended behaviour, but it is not what the current operand order produces.

Documentation fallout

Documentation.docc/UnicodePathNormalization.md is the package's article on exactly this mechanism, and this PR makes part of it false:

  • Line 66: "Both helpers retain a raw-value fallback for rows whose normalized properties are empty during migration or recovery." Not true of either helper any more.
  • Line 44: names migration 203 as the only backfill mechanism. The new startup repair belongs here — the invariant the change now rests on is stated nowhere else in the docs.

Adjacent and pre-existing, not blocking

Both predate this PR. I mention them only because they sit directly on the path it touches.

  • FilesDatabaseManager.itemMetadata(account:locatedAtRemoteUrl:) (around line 203) takes an account and logs with it, but its where clause constrains neither account nor deleted — it is hasLocation alone. So the "row occupying a logical address" lookup can return a tombstone. Every other logical-address query in that file adds !item.deleted.
  • hasServerUrl(includingDescendants: true) is still an OrNode, and StringNode<BeginsWith> gets no IndexEvaluator (only StringNodeEqualBase does), so the descendant form still cannot use an index. The win is real but scoped to the exact-match form, which is worth knowing before someone reads the new doc comment as covering both.

Minor

  • The checklist ticks the performance-test box, but no benchmark harness ships in the diff and none exists in the repo, so the numbers are not reproducible from the branch. Not asking for one — just noting the box overstates what is here.
  • The green Tests check does not cover this Swift package; Build and test File Provider client was still pending when I looked.

Review produced with Claude Code (claude-opus-5), findings verified against the checked-out branch and the pinned realm-core sources.

Comment on lines +16 to +17
item.normalizedServerUrl == serverUrl.precomposedStringWithCanonicalMapping
&& item.normalizedFileName == fileName.precomposedStringWithCanonicalMapping

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that both conjuncts are indexed-equality nodes, the order of these two operands decides which index Realm drives the query off — and as written it picks the worse one.

realm-core has no cardinality estimate at plan time. ParentNode::init sets m_dD = 100.0 unconditionally (query_engine.hpp:152; the only other writes to m_dD are inside aggregate_local, i.e. during execution), and StringNodeEqualBase::init sets m_dT = 0.0 whenever has_search_index() (query_engine.cpp:270-283). With cost() = 8 * bitwidth_time_unit / m_dD + m_dT (query_engine.hpp:143), both nodes score exactly 8 * 64 / 100 + 0 = 5.12 — bit-identical doubles.

Query::find_best_node resolves that with std::min_element and a strict < comparator (query.cpp:1166-1174), so it returns the first minimum. Node order is source order: RealmSwift emits lhs before rhs when building the compound predicate, RLMQueryUtil walks NSAndPredicate subpredicates front to back, Query::add_node appends via add_child, and gather_children inserts the root first. So m_children[0] is the normalizedServerUrl node, it wins the tie, and do_find_all then fetches and evaluates every key in that index bucket — which for a flat folder is the entire sibling set.

Swapping the operands drives it off normalizedFileName instead, whose bucket is the rows sharing that exact normalized name table-wide. Vastly smaller than a sibling set in the /Talk-style case this targets, though not O(1):

item.normalizedFileName == fileName.precomposedStringWithCanonicalMapping
    && item.normalizedServerUrl == serverUrl.precomposedStringWithCanonicalMapping

This is not a regression — on master both conjuncts were OrNodes with no index_based_keys(), so the order was irrelevant and the PR is a strict improvement either way. It is just that the order becomes load-bearing precisely because of this change, so it is worth pinning down here with a comment noting that the tie is broken by position.

/// A write transaction is opened only when at least one bucket has
/// more than one row, so clean databases pay no transaction cost.
///
///

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new function landed inside cleanupPreexistingLogicalDuplicates's doc comment, so the two have swapped documentation.

Everything from line 111 down to line 126 — "One-shot startup pass that heals pre-existing logical duplicates", the bucketing and winner-selection rules, the in-flight-row contract, and "A write transaction is opened only when at least one bucket has more than one row, so clean databases pay no transaction cost" — now attaches to backfillMissingNormalizedLocationKeys, which does none of those things. cleanupPreexistingLogicalDuplicates at line 155 is left with no documentation at all, and DocC will publish the deduplication description on the backfill symbol.

Moving the new /// block (128-131) and its function below cleanupPreexistingLogicalDuplicates, or just relocating the original block back onto it, fixes both.

Comment on lines +136 to +137
.where { $0.normalizedFileName == "" || $0.normalizedServerUrl == "" }
.filter { !$0.fileName.isEmpty || !$0.serverUrl.isEmpty }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two predicates disagree, so the repair is not a fixed point for a row with exactly one empty raw column.

The where is a per-column emptiness test; the filter is an OR across the raw columns. A row with fileName == "" and a non-empty serverUrl satisfies both, so it is selected — but the repair writes "".precomposedStringWithCanonicalMapping, which is "", straight back into the empty side. The row still matches on the next open.

Since this is called from FilesDatabaseManager.init, that means every construction of the manager emits the logger.error at line 141 and commits a write transaction. Not a no-op at the Realm level either: Realm does not diff assignments, so identical-value writes still record modification instructions, bump the version, and fire change notifications on those rows.

Reachability is low — I could not find a production path that leaves either raw column empty (the synthesised root uses fileName: "/", the PROPFIND fixup uses nkCommonInstance.rootFileName, trash rows carry a non-empty trashUrl, and nothing in Sources/ assigns serverUrl: ""), so in practice this is log noise rather than a correctness problem. Lookups are fine regardless, because hasLocation matches "" == "" on the indexed key perfectly well.

Making the predicate a per-column drift check rather than an emptiness check makes it idempotent and covers stale keys at the same time:

.filter {
    $0.normalizedFileName != $0.fileName.precomposedStringWithCanonicalMapping
        || $0.normalizedServerUrl != $0.serverUrl.precomposedStringWithCanonicalMapping
}

That cannot be a Realm where clause, so it needs the indexed emptiness where kept in front of it as a cheap prefilter — or accept the scan, since cleanupPreexistingLogicalDuplicates immediately after already iterates every non-deleted row into a dictionary, which dominates it either way.

let database = manager.ncDatabase()
try database.write {
let row = database.objects(RealmItemMetadata.self).where { $0.ocId == "renamed" }.first
row?.updateLocation(serverUrl: serverUrl, fileName: "after.txt")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls updateLocation directly, which is the thing being asserted, so the test cannot fail for the reason its name describes.

updateLocation writes both raw columns and both normalized keys in one place (RealmItemMetadata.swift:196-201), so "a rename moves the normalized keys with the row" is true here by construction. The risk the test is presumably guarding against is a rename path that forgets to go through it — and neither public entry point is exercised: FilesDatabaseManager.renameItemMetadata(ocId:newServerUrl:newFileName:) or renameDirectoryAndPropagateToChildren(ocId:newServerUrl:newFileName:), the latter being the one that rewrites descendant serverUrls in bulk.

Driving the test through renameItemMetadata would keep the same assertions and actually cover production code.

@i2h3

i2h3 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Note: we intend to throw out Realm as soon as possible because it is long end-of-life. I just did not get to it yet due to the overall situation. 🏠🔥

@i2h3 i2h3 changed the title perf(file-provider): answer logical-address lookups from the index Indexed Logical Address Lookups Sep 8, 2026
@i2h3

i2h3 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

/backport to stable-34.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants