Keep the migration running the upload inline (Artisan::call), not dispatched
to the queue. The local emoji files live on the web container's storage where
migrations run; a queued Horizon worker may not share that storage in future
(web and horizon storage decoupled), which would make a queued job a no-op.
Reverts the EmojiMigrateToCloudPipeline job approach.
Running the ~25k-file S3 upload synchronously via Artisan::call inside the
migration blocked the upgrade with no visible output, and a mid-run failure
would leave the migration in a bad state.
Dispatch EmojiMigrateToCloudPipeline (ShouldQueue, ShouldBeUnique) onto the
mmo queue instead, so migrate returns immediately and the upload runs in the
background on Horizon. The job re-checks the cloud-storage guard at runtime,
runs the async command (--concurrency=100), has a 1h timeout and tries=1,
and is unique so it can't stack up.
Instead of scanning all emoji, target specific files by name:
admin:resyncemoji "26109.png,1234.gif"
Looks up each by media_path (emoji/{filename}), and for found remote emoji
re-downloads from image_remote_url onto the active disk. Reports per-file
status (resynced/skipped/failed/not_found). --missingonly still guards each
against the disk so present files are left alone; --dry-run and --force.
Re-fetches remote custom emoji media from image_remote_url (SSRF-hardened via
SecureMediaFetchService) and stores it on the active disk. Useful to repair
emoji whose stored file went missing.
- --missingonly checks each emoji's file on the active disk (cloud when cloud
storage is enabled) and only re-downloads the ones that are absent
- --dry-run, --limit, --force
- CustomEmojiService::resync() does the per-emoji fetch+store; CustomEmoji
gains a mediaExists() helper
CommandPool re-indexes promises by default, so the key passed to the
fulfilled/rejected callbacks did not reliably map back to $files[$key] on
out-of-order async completions. This mismapped results to the wrong file and
could delete a local copy whose upload belonged to (or failed for) a
different file, leaving gaps on cloud (404s).
Key the command generator by the local path and set preserve_iterator_keys
so callbacks receive the exact path they correspond to. No more index math.
Re-land the emoji cloud-storage work on a clean staging base, using the
async AWS SDK upload path.
- CustomEmoji model: cloud-aware URL + storage helpers (urlForPath, url,
storageTarget, storeMedia, storeMediaFromFile, deleteMedia)
- Route emoji writes/deletes/URLs through the model in ImportEmojis,
CustomEmojiService and AdminController; admin views use $emoji->url()
- admin:EmojiMoveStorageLocalToCloud: disk-driven migration using the AWS
SDK CommandPool with --concurrency (default 100) for high throughput;
skips missing.png; --dry-run/--keep-local/--offset/--limit/--no-acl/--debug
- Deploy migration + daily schedule under the cloud-storage conditional
Back out all emoji cloud-storage work from staging so it can be reworked and
re-landed separately (the URL resolution flips to cloud on a global config
flag, which created a broken-URL window, and the migration approach needs
revisiting).
Reverts to pre-emoji state:
- CustomEmoji model URL/storage helpers (urlForPath, storageTarget, storeMedia,
storeMediaFromFile, deleteMedia, url) and callers in ImportEmojis,
CustomEmojiService, AdminController
- admin custom-emoji blade views back to local /storage URLs
- Remove admin:EmojiMoveStorageLocalToCloud command
- Remove the deploy migration and its scheduler entry
Media (and the already-reverted story) scheduler entries are untouched.
Process-level workers plateaued at ~7.5 uploads/sec against Fastly Object
Storage because each PUT is high-latency and only a handful ran concurrently.
Add --concurrency=N which uses the AWS SDK CommandPool to keep N PutObject
requests in flight from a single process. A successful PutObject response is
the confirmation (no separate HEAD verify), and the local file is deleted on
success. Commands are yielded lazily so memory stays flat over large runs.
--no-acl escape hatch for S3-compatible stores that reject the ACL header.
- Live 'up/s' rate shown on the progress bar during single-worker runs
- Final summary reports elapsed time and uploads/sec
- Parallel runs tally moved across workers and report aggregate uploads/sec
Makes it easy to compare --workers counts and decide whether async S3
(option 2) is worth pursuing.
The migration was ~1-2s/file due to sequential S3 round-trips (HEAD + PUT +
verify HEAD). Speed it up:
- --workers=N spawns N child processes, each handling a strided slice of the
files (index % N == shard) for real concurrency on the I/O-bound uploads
- --skip-cloud-check skips the upfront HEAD (always upload, idempotent)
- --skip-verify skips the post-upload size re-check
- --offset for manual chunking
Storage/Flysystem has no batch or async upload API, so process-level
concurrency is the pragmatic lever here.
The frontend renders a hardcoded /storage/emoji/missing.png local onerror
fallback for emoji, so that placeholder must stay on local disk. Skip it in
the migration so it is never moved to cloud or deleted locally.
The migration was DB-driven (whereNull('uri')), which excluded federated
emoji whose media is stored locally but have a uri set -> the disk was never
scanned, resulting in moved=0.
- Drive the migration by enumerating local files under public/emoji/ instead
of a DB query; the local file is the source of truth for what needs moving
- Add --debug to print config, custom_emoji table breakdown, local emoji dir
contents, and per-file decisions
Back out the story local->cloud migration so we can land and verify the
emoji cloud work first, one change at a time.
Reverts:
- 9f110bb74 feat: migrate local story media to cloud storage
- 842681b99 fix: schedule StoryMoveStorageLocalToCloud command
Removes StoryMoveStorageLocalToCloud command, its scheduler entry, and the
StoryExpire explicit-disk changes. Remcache and emoji work are untouched.
Will revisit story once emoji is confirmed.
Two issues prevented emoji from serving/migrating correctly on cloud:
- Admin custom-emoji views hardcoded url('storage/'.media_path), so they
always showed local URLs and bypassed cloud resolution. Use $emoji->url().
- The migration/command guard relied solely on config_cache('pixelfed.cloud_storage'),
which is DB/12h-cached and can read stale-false right after cloud is
enabled, causing the migration to silently no-op. Treat cloud as enabled
when either live config() or config_cache() is true.
The story cloud-migration command was merged (#6958) but its scheduler
entry was dropped when the emoji branch (based off staging before that
merge) later merged and overwrote the scheduler block. Restore the hourly
schedule so local story media is migrated to cloud automatically.
Runs admin:EmojiMoveStorageLocalToCloud during migrate so existing local
emoji are relocated to cloud as part of the upgrade, shrinking the window
where emoji URLs resolve to cloud before the files are there. No-op unless
cloud storage is enabled.
Change --limit default to 0 (no limit) so the emoji migration processes
every local emoji in a single run instead of capping at 1000, and drop the
limit from the scheduled invocation. Avoids a multi-run window where
not-yet-migrated emoji resolve to missing cloud URLs.
Custom emoji were always written locally and served via hardcoded /storage
URLs, so they never used S3 even on cloud instances.
- CustomEmoji: centralize URL + storage on the active disk (cloud when
pixelfed.cloud_storage is enabled, else local public/ disk) via
urlForPath/url/storageTarget/storeMedia/storeMediaFromFile/deleteMedia
- Route emoji writes/deletes and URL generation (scan, CustomEmojiService::all)
through those helpers in ImportEmojis, CustomEmojiService::import and
AdminController
- Add admin:EmojiMoveStorageLocalToCloud to migrate existing local emoji to
cloud: copy, verify by size, delete local, bust caches
- Schedule it daily when cloud storage is enabled
Ensure story media lands on and stays on cloud storage for S3 instances.
- StoryExpire: archive expiring story media on the same explicit disk the
media lives on (S3 move is a server-side copy+delete), with error handling
- Add admin:StoryMoveStorageLocalToCloud to migrate local story media
(active + story_archives) to cloud: copy, verify by size, then delete local
- --orphans option relocates untracked story_archives/ files to cloud using
the same copy/verify/delete flow (media is moved, never discarded)
- Schedule it hourly alongside the media migration when cloud storage is on
The remote avatar/media fetchers wrote temp files to storage/app/remcache/
and only unlinked them on the happy path. Any exception between the write
and the unlink (e.g. a cloud upload failure) leaked the file, and nothing
swept the directory.
- Wrap post-write logic in fetchAvatar() and remoteToCloud() in try/finally
so the temp file is always removed, even on failure
- Add gc:remcache command to delete stale remcache files (default >24h old,
preserves .gitignore, supports --hours and --dry-run)
- Schedule gc:remcache daily to clean up any stragglers
StoryFetch already handled cleanup via try/catch and was left unchanged.
- Add return type hints (void) and final class markers
- Guard null returns from newestBackup() and putFileAs() in BackupToCloud
- Type ask() default values as strings
- Fix uses_left fallback condition for null/zero max_uses
- Annotate AdminInvite::whereInviteCode and cast Str::uuid() to string
- Ignore local redis-data and mysql-9-data dev directories
Adds a psalm:report script that ignores the baseline and writes a full
human-readable report to psalm-report.txt, including informational issues,
so all outstanding items to fix are surfaced in one file.
Migration failed with a duplicate entry error: recollating to
utf8mb4_unicode_520_ci causes previously-distinct hashtag names/slugs
to collide on the unique indexes. Reverting until the data is
de-duplicated first.