fix(trashbin): avoid full filecache scan when restoring versions (#31682)#41641
fix(trashbin): avoid full filecache scan when restoring versions (#31682)#41641DeepDiver1975 wants to merge 2 commits into
Conversation
`Trashbin::getVersionsFromTrash()` looked up a deleted file's versions with
`View::searchRaw('<name>.v%[.d<timestamp>]')`, which reaches
`Cache::search()` as `WHERE storage = ? AND name ILIKE ?`. The leading
literal + trailing `%` is non-sargable, and on MySQL the `ILIKE`->`COLLATE`
rewrite defeats any `name` index entirely, so every version restore — and
every `ExpireTrash`/`ExpireVersions` cron run that restores versions — full
scans `oc_filecache`. On large installations this is a heavy, repeated load
spike (issue #31682).
The version files for a given deleted file all live in a single known
directory under `files_trashbin/versions` (the folder the file was in, or
the versions root). Replace the whole-cache name search with an index-backed
`getDirectoryContent()` of that one directory (uses the existing
`(parent, name)` index) and filter the `<name>.v<version>[.d<timestamp>]`
entries in PHP. The directory to list is now passed in from both call sites
so version files of files deleted from inside a folder are still found.
Behavior is preserved: the same versions are returned, in the same shape.
Literal PHP matching is also slightly stricter than the previous SQL `LIKE`
(filenames containing `_`/`%` are no longer treated as wildcards).
Adds a regression test covering both the timestamped root lookup and the
non-timestamped sub-folder lookup, and asserting that version files of other
files / other timestamps are ignored.
Fixes #31682
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
🤖 Automated code review by Claude Code review agent
Overview
This PR fixes a real and well-diagnosed performance bug (#31682): Trashbin::getVersionsFromTrash() ran a non-sargable name ILIKE '<file>.v%[.d<ts>]' search that, combined with the MySQL ILIKE→COLLATE rewrite, full-scanned oc_filecache on every version restore / trash+version cron run. Replacing it with an index-backed getDirectoryContent() of the single known versions directory + PHP-side filtering is the right, appropriately-scoped approach (no schema change, no change to the generic Cache::search()).
The core fix looks correct, including the subtle part: passing $dir from both call sites so version files of files deleted from inside a sub-folder are still found (the old whole-cache search matched those by name regardless of location). 👍
🔴 Blocking — the new test will fail CI
In testGetVersionsFromTrash(), the first assertion is:
$versions = $method->invoke(null, 'test.txt', $timestamp, $this->user);
\sort($versions);
$this->assertEquals(['1', '10', '2'], $versions);sort() defaults to SORT_REGULAR, which sorts the numeric strings '1','2','10' numerically → ['1', '2', '10']. But the test asserts ['1', '10', '2']. assertEquals compares sequential arrays element-by-element (no canonicalization by default), so this fails:
mismatch at [1]: expected '10', got '2'
mismatch at [2]: expected '2', got '10'
Fix — make the expectation match numeric sort order:
$this->assertEquals(['1', '2', '10'], $versions);(The second assertion, ['5', '6'], is fine.) An even more robust option is sort($versions, SORT_STRING) paired with a string-sorted expectation, or assertEqualsCanonicalizing() — but the minimal correct change is just reordering the expected array. Since the suite is @group DB it can't be run in the agent's sandbox, which is exactly why this slipped through; worth running locally/CI before merge.
Minor / non-blocking
$prefixmatching is a substring-prefix, not a segment boundary.\strpos($name, $filename . '.v') !== 0correctly anchors at the start, and the'.backup.v9'ignore-case in the test confirms a longer name isn't matched. Good. One residual edge: a sibling file literally named<filename>.vNN...where<filename>is a prefix of another real filename is already handled because you match the full$filename+.v. No action needed — just noting it was checked.- Comment/style: matches surrounding code (tabs,
\-prefixed globals). Good. - Behavior note in the PR description is accurate: literal PHP matching is stricter than SQL
LIKEfor filenames containing_/%; that's a latent correctness improvement, not a regression.
Test coverage
Good instinct adding both the timestamped-root and non-timestamped-subfolder cases plus the "ignore other files/timestamps" entries — that subfolder case is precisely the regression risk of this change. Once the assertion above is corrected, coverage is solid. Consider also asserting the empty-result case (lookup for a filename with no versions) since getDirectoryContent on a non-existent dir returns [].
Verdict
Approach and core logic are correct; one blocking test bug (assertEquals ordering) must be fixed before this can pass CI. Everything else is minor.
The first assertion in testGetVersionsFromTrash() expected ['1', '10', '2'], but sort() uses SORT_REGULAR and sorts the numeric version strings numerically -> ['1', '2', '10'], so the test failed. Correct the expected order and add a case asserting an empty result for a file that has no stored versions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
dj4oC
left a comment
There was a problem hiding this comment.
Replaces an unscoped wildcard filecache search (View::searchRaw()) with a folder-scoped View::getDirectoryContent() + PHP-side filtering in Trashbin::getVersionsFromTrash(), intended to avoid a full filecache scan when restoring/deleting versions (issue #31682).
Findings
- Security: no findings —
$diris derived from the file's own trash path (not raw user input) andViewis jailed per-user; no traversal risk. Correctly scoped as a CWE-400 (uncontrolled resource consumption) mitigation. - Stability: an earlier self-review claim that the test assertion (
sort()on['1','10','2']) would fail CI was independently checked and refuted — the actual assertion is['1', '2', '10'], which is correct, and PHP'ssort()underSORT_REGULARdoes compare numeric strings numerically. Current CI is green (confirmed live, not from a stale claim). - Performance (blocking) — the stated goal may not hold in the dominant real-world case.
View::getDirectoryContent()fully hydrates aFileInfoobject (owner/share/mount-point resolution included) for every entry in the target directory before the new PHP loop discards non-matching ones. For files trashed directly at the trashbin root — arguably the most common case, and the case this method is called for per-file during bulk folder restores/deletes —$lookupDirresolves to the flatfiles_trashbin/versionsroot, which accumulates entries for every directly-trashed file for that user. Each call re-fetches and re-hydrates that entire directory from scratch with no caching/reuse across files in one bulk operation, which can be as expensive as (or worse than) the unscoped scan it replaces for users with large trash bins. Recommend caching the directory listing per unique$diracross a single bulk restore/delete operation, or using a cheaper indexed lookup that doesn't force fullFileInfohydration. - Test coverage: the added test never exercises
$dir === '.'— the valuedirname()actually returns for root-level trashed files, which is precisely why the code has a special ternary to normalize it. That's the dominant real-world call pattern and it's currently unverified by any test.
Verdict
Changes requested — not for a security or correctness bug (both refuted-vs-confirmed claims land in this PR's favor), but the performance goal isn't demonstrated for the common case, and the one branch written to handle that common case has no test.
🤖 Automated review by Claude Code (security · stability · performance · coverage)
Generated by Claude Code
| // The version files for a deleted file all live in a single, known | ||
| // directory under "files_trashbin/versions" (the folder the file was in, | ||
| // or the versions root). Instead of running a non-sargable | ||
| // trailing-wildcard name search across the whole filecache (full table | ||
| // scan on MySQL, see issue #31682), list that one directory using the | ||
| // index-backed (parent, name) lookup and filter the matching | ||
| // "<filename>.v<version>[.d<timestamp>]" entries in PHP. | ||
| $lookupDir = ($dir === '/' || $dir === '.') ? '' : \ltrim($dir, '/'); | ||
| $prefix = $filename . '.v'; | ||
|
|
||
| if ($timestamp) { | ||
| // fetch for old versions | ||
| $matches = $view->searchRaw($filename . '.v%.d' . $timestamp); | ||
| // match "<filename>.v<version>.d<timestamp>" | ||
| $suffix = '.d' . $timestamp; | ||
| $offset = -\strlen($timestamp) - 2; | ||
| } else { | ||
| $matches = $view->searchRaw($filename . '.v%'); | ||
| // match "<filename>.v<version>" | ||
| $suffix = ''; | ||
| $offset = 0; | ||
| } |
There was a problem hiding this comment.
It might be worthy to move this piece to a public helper. We can add specific unit tests for this part there.
| $name = $entry->getName(); | ||
| if (\strpos($name, $prefix) !== 0) { | ||
| continue; | ||
| } | ||
| if ($suffix !== '') { | ||
| // must end with the deletion timestamp suffix | ||
| if (\substr($name, $offset) !== $suffix) { | ||
| continue; | ||
| } | ||
| $parts = \explode('.v', \substr($name, 0, $offset)); | ||
| } else { | ||
| $parts = \explode('.v', $name); | ||
| } | ||
| $versions[] = (\end($parts)); |
There was a problem hiding this comment.
We can try to move this to another function. A extractVersionFromVersionedFile looks better to describe what this code is doing.
Summary
Trashbin::getVersionsFromTrash()looks up a deleted file's versions withView::searchRaw('<name>.v%[.d<timestamp>]'), which reachesCache::search()as:The pattern is a leading literal followed by a trailing
%, which is already non-sargable; on MySQL/MariaDB theILIKE→COLLATE ..._general_ci LIKErewrite (AdapterMySQL) additionally defeats any index onname. As a result every version restore — and everyExpireTrash/ExpireVersionscron run that restores versions — performs a full table scan ofoc_filecache. On installations with a large filecache this is a heavy, repeated DB load spike. (Fixes #31682.)Change
The version files for a given deleted file all live in a single, known directory under
files_trashbin/versions(the folder the file was in, or the versions root). This PR replaces the whole-cache name search with an index-backed directory listing of that one directory (View::getDirectoryContent(), which uses the existing(parent, name)index) and filters the<name>.v<version>[.d<timestamp>]entries in PHP.The directory to list is passed in from both call sites, so version files of files deleted from inside a folder are still found (the previous whole-cache search matched those by name regardless of location — the directory listing must look in the right sub-folder, not just the versions root).
Scope
db_structure.xmlchange.Cache::search()/AdapterMySQLILIKEbehavior — the fix is contained to the trashbin version-restore hot path.Behavior / compatibility
LIKE: filenames containing_or%are no longer treated as wildcards (a latent edge-case bug in the old code), so if anything the result set is more correct, never broader.Tests
Adds
StorageTest::testGetVersionsFromTrash()covering:🤖 This PR was prepared by the Claude Code review agent from the analysis of #31682. Please review carefully before merging.