Skip to content

[2.x] fix: track avatar HiDPI variants on users to remove srcsetFor() filesystem calls#4639

Merged
imorland merged 3 commits into2.xfrom
im/avatar-variants-flags
May 7, 2026
Merged

[2.x] fix: track avatar HiDPI variants on users to remove srcsetFor() filesystem calls#4639
imorland merged 3 commits into2.xfrom
im/avatar-variants-flags

Conversation

@imorland
Copy link
Copy Markdown
Member

@imorland imorland commented May 7, 2026

Summary

AvatarUploader::srcsetFor() called Filesystem::exists() three times per locally-stored avatar per serialization. On local-disk installs this is essentially free, but on S3-backed flarum-avatars each call is a HEAD round-trip. A typical /api/discussions render with ~13 distinct users with avatars produced ~39 HeadObject calls and ~840ms of TTFB attributable to this single accessor.

This PR records HiDPI variant presence on the users row instead of inferring it from the filesystem on every read. Two boolean columns (has_avatar_2x, has_avatar_3x) are set when upload() / uploadPresized() actually writes the variant files, and cleared on remove(). 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 — caches srcsetFor() 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 = 39 exists() 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:

  • MySQL 8.0+ / MariaDB 10.3+: ADD COLUMN ... DEFAULT <constant> is instant, no table rewrite, no lock.
  • PostgreSQL 11+: Same, metadata-only and instant.
  • SQLite: Always fast (column default written to schema).

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 — adds has_avatar_2x and has_avatar_3x (boolean, default 0) to the users table.

Production code

  • framework/core/src/User/User.php — adds the columns to $casts (boolean) and @property docblock; getAvatarSrcsetAttribute() now passes $this to srcsetFor().
  • framework/core/src/User/AvatarUploader.php:
    • srcsetFor(User $user) — reads columns, no filesystem calls. Signature change from srcsetFor(string $basePath) (only production caller is User::getAvatarSrcsetAttribute()).
    • upload() — tracks which sizes were actually written and stamps the flags accordingly.
    • uploadPresized() — sets flags based on which ImageInterface arguments were non-null.
    • remove() — clears both flags.
  • framework/core/src/User/Console/BackfillAvatarVariantsCommand.php (new) — php flarum avatars:backfill-variants command. Iterates locally-stored avatars, runs exists() per variant, writes the result. --chunk option (default 100) for throttling on remote storage; --force to 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 — updated srcsetFor() callers to pass User; added new tests for uploadPresized flag-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

  • Existing avatars default to has_avatar_2x = false, has_avatar_3x = false. The 1× URL still works (it's based on avatar_url); HiDPI degrades to 1× until a re-upload or until operators run php flarum avatars:backfill-variants.
  • AvatarUploader::srcsetFor() signature change is the only public API break. The only production caller is User::getAvatarSrcsetAttribute(), which is updated. Any extension subclassing or calling srcsetFor() directly will need to pass User instead of string. Worth a note in the 2.0 dev upgrade guide.

Test plan

  • Unit tests: 14/14 pass (AvatarUploaderTest).
  • Integration tests: 6/6 pass (AvatarSrcsetTest).
  • All user API tests: 103/103 pass.
  • Full unit suite: 289/289 pass.
  • Manual: tested locally — upload generates flags, srcset reflects them, delete clears.
  • Manual on S3-backed install (if available): confirm srcsetFor() makes zero filesystem calls; backfill command populates rows correctly.

Operator notes

After upgrade:

  1. Migration runs automatically on php flarum migrate — instant, no downtime.
  2. Existing users with HiDPI variants on disk will lose the HiDPI srcset until either:
    • They (or anyone else) re-upload an avatar (auto-populates the flags), or
    • The operator runs php flarum avatars:backfill-variants (one-shot, can run during low-traffic).
  3. The 1× avatar continues to work for everyone immediately. The visual regression is limited to HiDPI displays, where the browser will substitute the 1× until backfill.

Notes

  • The install dumps (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-GA GenerateDumpCommand run can refresh the dumps to include them natively.

…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
@imorland imorland requested a review from a team as a code owner May 7, 2026 00:15
@imorland imorland modified the milestone: 2.0.0-rc.2 May 7, 2026
imorland added 2 commits May 7, 2026 01:25
…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.
…file

Data\Assets::delete() removed only the base avatar file, leaving
@2x and @3x variants orphaned on disk after a GDPR user erasure.
Delegate to AvatarUploader::deleteAllVariants() so every variant
that exists is removed.
@imorland imorland merged commit 54bb287 into 2.x May 7, 2026
25 checks passed
@imorland imorland deleted the im/avatar-variants-flags branch May 7, 2026 00:37
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.x] srcsetFor() on AvatarUploader makes 3 remote exists() calls per user serialized — ~120ms+ per page on remote-disk installs

1 participant