fix(media): keep cached derivatives a sibling attachment still uses - #152
fix(media): keep cached derivatives a sibling attachment still uses#152parisek wants to merge 2 commits into
Conversation
cleanup_cached_images() matched the resizer cache by basename alone. The cache is keyed by file, but one file routinely carries several attachment rows -- WPML writes one per language, and a duplicate upload can be pointed at an existing path -- so deleting any one row took the shared derivatives with it. Measured on a five-language site: 5542 files shared by 25981 attachment rows. The homepage hero lost its derivatives while five rows and the rendered page still referenced them. The guard fails closed. Where the sibling question cannot be answered the files are kept: a stale derivative is overwritten by the next resize, while one deleted in error disappears from a page that is still serving it. Deliberately NOT done here: - No StarterBase feature flag. The convention puts behaviour changes behind one, default off, but that would leave a data-loss path live for every consumer until they opted out of it. Nothing observable is added; a delete that destroyed in-use files stops happening. - The basename collision across upload-year folders is untouched. 336 basenames map to more than one file, and the flat cache namespace cannot tell them apart. That is a cache-naming decision, not a guard, and it gets its own issue. - The query uses %i rather than interpolating $wpdb->postmeta, which keeps the string literal for PHPStan instead of buying silence with an ignore.
Review of the previous commit found the fail-closed guard was not closed. get_var() reports a failed query by returning null, and (int) null is the same 0 a genuine "no siblings" answer gives -- so any transient database error read as "not shared" and deleted the files the guard exists to keep. The docblock already promised the opposite. Null and last_error are now both checked. An empty _wp_attached_file flips the same way: get_attached_file() already returned a path, so an empty meta value means a filter supplied it (offloaded media) and siblings have no key to match on. Four tests added, three of them red before this change. The fourth pins the SQL itself -- the wpdb stub returns a count whatever the query says, so without it the suite would pass against the wrong table, the wrong meta key, or a dropped post_id exclusion. Test teardown now removes only the paths it created. WP_CONTENT_DIR is a bootstrap constant, so the cache directory cannot be varied per run and deleting the tree wholesale would take a concurrent run's fixtures with it. Found by Codex (gpt-5-codex) reviewing PR #152. %i is kept: the reviewer read its WP 6.2 floor as a silent-delete path, and that consequence is what this commit removes -- an unsupported placeholder now fails the query, and a failed query keeps the files. Interpolating the table instead would have reintroduced the PHPStan literal-string error for no safety gain.
Agent review — Codex (gpt-5-codex), 2026-08-26Ran as an independent adversarial pass over the diff. Four findings; two were real FixedThe fail-closed guard was not closed. An empty The stub validated nothing. It returned a preselected count whatever the SQL Teardown deleted files it did not create. Rejected
Worth recording from the confirmationsThe reviewer checked the load-bearing premise against local WP 6.8.1 core rather It also noted the query does not filter Tests 1699 + 18 green, PHPStan clean, |
From project
sloneek, staging. The homepage hero rendered with a black background. Thederivative under
wp-content/cache/image/900x0-center/homepage-hero-desktop.avifhad been deleted and regenerated by a broken ImageMagick build — but it should
never have been deleted at all: the file, five attachment rows and the rendered
page all still referenced it.
Why
cleanup_cached_images()runs ondelete_attachmentand matches the cache bybasename alone. The cache is keyed by file; one file routinely carries
several attachment rows:
Deleting any one of those rows wiped the derivatives every other row was still
using.
Measured on that site:
Roughly four fifths of the media library could take a live image down with it.
The failure is silent — no error, nothing in a log, and the damage only shows up
when someone looks at the page.
What changed
The delete runs only when no other attachment points at the same
_wp_attached_file. Comparison is on that meta value because it is whatsiblings share;
get_attached_file()is filtered and absolute, so it is notcomparable across rows.
The guard fails closed: where the question cannot be answered, the files are
kept. A stale derivative is overwritten by the next resize; one deleted in error
vanishes from a page that is still serving it.
Tests
Written first, red for the right reason, then green (
tests/Unit/StarterBase/CleanupCachedImagesTest.php):composer test:all1695 + 18 green,composer phpstanclean,composer adrOK.Deliberately not done
StarterBasefeature flag. The convention puts behaviour changes behindone, default off. Applying it here would leave a data-loss path live for every
consumer until they opted out of it. Nothing observable is added — a delete
that destroyed in-use files stops happening. Flagging this is the reviewer's
call to overturn; I did not want to make it silently.
basename across upload-year folders (
2022/03/11.pngand2022/10/11.png)collide in the flat cache namespace, so deleting one still removes the other's
derivative. 336 basenames on that site map to more than one file. That needs a
cache-naming change, not a guard, and it deserves its own discussion — the
fix invalidates every existing cached derivative.
%iinstead of interpolating$wpdb->postmeta, which keeps the queryliteral for PHPStan rather than buying silence with an ignore.