Skip to content

Fixes #279 - #280

Open
marcelklehr wants to merge 2 commits into
mainfrom
fix/indexing-correctness-perf
Open

marcelklehr wants to merge 2 commits into
mainfrom
fix/indexing-correctness-perf

Conversation

@marcelklehr

Copy link
Copy Markdown
Member

QueueController::setInitialIndexCompletion() runs on every
DELETE /queues/documents request from the backend. On instances where it
falls through to the file counting path, every one of those requests walks
all mounts of the instance via StorageService::countFiles(). With several
hundred group folders that is minutes of SELECT COUNT(*) FROM oc_filecache
per request, several of them concurrently, and it never stops.

Four issues conspire here:

  • last_enqueued_db_id is only written by StorageCrawlJob and backfilled by
    Version005004000Date20260302135634 while the referenced file is still
    queued. Instances that finished their crawl before the value existed never
    get it, so the cheap completion check is skipped and the counting fallback
    runs forever. Seed the value from the current queue head instead, and treat
    an empty queue with no pending crawl jobs as a completed initial index.

  • Even after counting, withinThreshold() compared the fraction of files that
    are already indexed against the threshold instead of the fraction still
    queued, so completion was never detected and the fallback could not
    terminate. 557 queued of 100000 eligible evaluated to 0.994 < 0.02.

  • withinThreshold() divided by zero when nothing is eligible for indexing.
    DivisionByZeroError is an Error, so neither the inner \OCP\DB\Exception
    catch nor the caller's \Exception catch stopped it from turning into a 500
    on the endpoint the backend needs to drain the queue. Guard the division and
    widen the caller's catch to \Throwable.

  • Keep the counting fallback as a last resort but throttle it to once an hour,
    so it can never again be driven by request volume.

Also count home mounts from their files/ folder, like the crawl in
getFilesInMount() does, rather than from the storage root. Counting from the
root included uploads/, cache/ and files_encryption/, inflating
eligible_files_count against files that are never queued for indexing.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI (N/A)

@nextcloud-command nextcloud-command added the AI assisted This PR contains AI-assisted commits label Sep 16, 2026
@marcelklehr
marcelklehr marked this pull request as ready for review September 16, 2026 13:06
`QueueController::setInitialIndexCompletion()` runs on every
`DELETE /queues/documents` request from the backend. On instances where it
falls through to the file counting path, every one of those requests walks
all mounts of the instance via `StorageService::countFiles()`. With several
hundred group folders that is minutes of `SELECT COUNT(*) FROM oc_filecache`
per request, several of them concurrently, and it never stops.

Four issues conspire here:

* `last_enqueued_db_id` is only written by `StorageCrawlJob` and backfilled by
  `Version005004000Date20260302135634` while the referenced file is still
  queued. Instances that finished their crawl before the value existed never
  get it, so the cheap completion check is skipped and the counting fallback
  runs forever. Seed the value from the current queue head instead, and treat
  an empty queue with no pending crawl jobs as a completed initial index.

* Even after counting, `withinThreshold()` compared the fraction of files that
  are *already indexed* against the threshold instead of the fraction still
  queued, so completion was never detected and the fallback could not
  terminate. 557 queued of 100000 eligible evaluated to `0.994 < 0.02`.

* `withinThreshold()` divided by zero when nothing is eligible for indexing.
  `DivisionByZeroError` is an `Error`, so neither the inner `\OCP\DB\Exception`
  catch nor the caller's `\Exception` catch stopped it from turning into a 500
  on the endpoint the backend needs to drain the queue. Guard the division and
  widen the caller's catch to `\Throwable`.

* Keep the counting fallback as a last resort but throttle it to once an hour,
  so it can never again be driven by request volume.

Also count home mounts from their `files/` folder, like the crawl in
`getFilesInMount()` does, rather than from the storage root. Counting from the
root included `uploads/`, `cache/` and `files_encryption/`, inflating
`eligible_files_count` against files that are never queued for indexing.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
`countFilesInMount()` took 1-7 seconds per mount on a 1.9M row file cache,
so a full count over a few hundred group folders ran for tens of minutes.

The dominant cost is the LIKE pattern. `_` and `%` are LIKE wildcards, and a
group folder root path is `__groupfolders/<id>`, so the unescaped pattern
`__groupfolders/28/%` has no literal prefix for the planner to work with.
fs_storage_path_prefix cannot be used for a range scan and every count falls
back to filtering the whole storage. Escape the prefix with
`escapeLikeParameter()`, which both restores the index range scan and stops
the pattern from matching unrelated paths. The legacy crawl path in
`getFilesInMountOld()` had the same unescaped pattern.

The end-to-end-encryption check was a correlated scalar subquery on the parent
row, re-executed for every candidate row. `fileid` is the primary key, so an
inner join on `filecache.parent` selects the same rows (a missing parent
excluded the row before, and excludes it now) while letting the planner
resolve it as a primary key lookup.

Also:

* Fetch the mount root path with a plain `SELECT path`, instead of
  `selectFileCache()` pulling every file cache column plus the metadata join
  to read a single field.
* Drop the duplicated `filecache.storage` predicate.
* Drop the `files_versions/` and `files_trashbin/` exclusions. Mounts are now
  counted from their overridden root, so neither can match a home mount's
  `files/` prefix, and the crawl in `getFilesInMount()` applies no such filter
  either, so leaving them made the count disagree with what is indexed.
* Cast the count before returning it. `fetchOne()` yields a string, which
  under `strict_types=1` would be a TypeError against the `int` return type.

Assisted-by: ClaudeCode:claude-opus-5
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
@marcelklehr
marcelklehr force-pushed the fix/indexing-correctness-perf branch from 523d823 to e9068bd Compare September 16, 2026 15:11
@marcelklehr

Copy link
Copy Markdown
Member Author

CI failure is preexisting, it seems

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

Labels

AI assisted This PR contains AI-assisted commits

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants