[2.x] fix: track avatar HiDPI variants on users to remove srcsetFor() filesystem calls#4639
Merged
[2.x] fix: track avatar HiDPI variants on users to remove srcsetFor() filesystem calls#4639
Conversation
…ystem calls AvatarUploader::srcsetFor() called Filesystem::exists() three times per locally-stored avatar per serialization — fine on local disk, but on S3 each call is a HEAD round-trip. A typical /api/discussions render with ~13 distinct users with avatars produced ~39 HeadObject calls and ~840ms TTFB attributable to this single accessor. Track which HiDPI variants exist on the user row instead. Add two boolean columns (has_avatar_2x, has_avatar_3x), set them when upload() or uploadPresized() actually writes the variant files, clear them on remove(). srcsetFor() reads the User's columns and never touches the filesystem — zero round-trips regardless of how many users render. Upload paths are unchanged in behavior: upload() still skips upscaling small sources (the column simply records what was written), uploadPresized() still respects null variant args. Migration is metadata-only (ADD COLUMN with constant DEFAULT) on every supported DB engine — instant even on forums with hundreds of thousands of users. Existing avatars default to has_avatar_2x = false / has_avatar_3x = false; the 1× srcset still works, only HiDPI degrades until backfill. Add a php flarum avatars:backfill-variants command for operators to populate the columns for pre-existing avatars. Iterates locally-stored avatars, runs exists() per variant, writes the result. --chunk option for throttling on remote storage; --force to re-check rows already flagged true. Fixes #4635
…ize() Data\User::anonymize() iterates every users column and sets it to null unless explicitly listed in the $remove allowlist. The new has_avatar_2x / has_avatar_3x boolean columns are NOT NULL, so the null-set hit a constraint violation: PDOException: SQLSTATE[23000]: NOT NULL constraint failed: users.has_avatar_2x Add both columns to $remove so they're skipped, and explicitly set them to false after the loop — the avatar files themselves are removed by Data\Assets::anonymize(), so the flags should match.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AvatarUploader::srcsetFor()calledFilesystem::exists()three times per locally-stored avatar per serialization. On local-disk installs this is essentially free, but on S3-backedflarum-avatarseach call is a HEAD round-trip. A typical/api/discussionsrender with ~13 distinct users with avatars produced ~39HeadObjectcalls and ~840ms of TTFB attributable to this single accessor.This PR records HiDPI variant presence on the
usersrow instead of inferring it from the filesystem on every read. Two boolean columns (has_avatar_2x,has_avatar_3x) are set whenupload()/uploadPresized()actually writes the variant files, and cleared onremove().srcsetFor()reads the columns and never touches the filesystem — zero round-trips regardless of how many users render.Fixes #4635
Why a migration in RC
I weighed two paths before settling on this one:
Per-request memoization on
AvatarUploader— cachessrcsetFor()results in-memory per request. Small change, no schema, but only deduplicates re-serializations of the same user within a request. The reporter's pathology is distinct users (~13 unique users × 3 = 39exists()calls) — memoization helps the overlap but each unique user still pays 3 round-trips. Doesn't actually fix the issue on cold caches.Persist variant info on the user row (this PR) — eliminates filesystem calls entirely on the read path. Cost is two columns and a migration. The migration itself is metadata-only on every supported Flarum 2.x DB engine:
ADD COLUMN ... DEFAULT <constant>is instant, no table rewrite, no lock.Forums with hundreds of thousands of users will see this migration complete in milliseconds. There's no scenario on supported engines where it triggers a table copy.
This made the persistent-state approach RC-safe: schema additions during RC are normal when they fix a real issue and don't change semantics for existing code (the columns default to
false, which produces the correct 1× srcset for any user — HiDPI just degrades gracefully until either re-upload or backfill).Changes
Schema
framework/core/migrations/2026_05_07_000000_add_avatar_variant_flags_to_users.php— addshas_avatar_2xandhas_avatar_3x(boolean, default0) to theuserstable.Production code
framework/core/src/User/User.php— adds the columns to$casts(boolean) and@propertydocblock;getAvatarSrcsetAttribute()now passes$thistosrcsetFor().framework/core/src/User/AvatarUploader.php:srcsetFor(User $user)— reads columns, no filesystem calls. Signature change fromsrcsetFor(string $basePath)(only production caller isUser::getAvatarSrcsetAttribute()).upload()— tracks which sizes were actually written and stamps the flags accordingly.uploadPresized()— sets flags based on whichImageInterfacearguments were non-null.remove()— clears both flags.framework/core/src/User/Console/BackfillAvatarVariantsCommand.php(new) —php flarum avatars:backfill-variantscommand. Iterates locally-stored avatars, runsexists()per variant, writes the result.--chunkoption (default 100) for throttling on remote storage;--forceto re-check rows whose flags are already true.framework/core/src/Console/ConsoleServiceProvider.php— register the new command.Tests
framework/core/tests/unit/User/AvatarUploaderTest.php— updatedsrcsetFor()callers to passUser; added new tests foruploadPresizedflag-setting, "no variants recorded → null srcset", "only 2× recorded → 2× in srcset, no 3×", and "no avatar → null". 14 tests, 54 assertions, all green.framework/core/tests/integration/api/users/AvatarSrcsetTest.php— added two new test users (one with both flags, one with 2× only) and two new test cases asserting the API exposes the correct srcset based on column values. 6 tests, 26 assertions, all green.Backward compatibility
has_avatar_2x = false,has_avatar_3x = false. The 1× URL still works (it's based onavatar_url); HiDPI degrades to 1× until a re-upload or until operators runphp flarum avatars:backfill-variants.AvatarUploader::srcsetFor()signature change is the only public API break. The only production caller isUser::getAvatarSrcsetAttribute(), which is updated. Any extension subclassing or callingsrcsetFor()directly will need to passUserinstead ofstring. Worth a note in the 2.0 dev upgrade guide.Test plan
AvatarUploaderTest).AvatarSrcsetTest).srcsetFor()makes zero filesystem calls; backfill command populates rows correctly.Operator notes
After upgrade:
php flarum migrate— instant, no downtime.php flarum avatars:backfill-variants(one-shot, can run during low-traffic).Notes
mysql-install.dump,mariadb-install.dump,pgsql-install.dump,sqlite-install.dump) don't include the new columns. The migration adds them on top — works for both fresh installs and upgrades. A pre-GAGenerateDumpCommandrun can refresh the dumps to include them natively.