For the rare cases where SQL must differ between MySQL and MariaDB,
add DatabaseDriver::isMysql()/isMariadb() and db_is_mysql()/db_is_mariadb()
alongside the existing grouped isMysqlMaria()/db_is_mysql_maria().
Laravel 11 exposes MariaDB as a dedicated 'mariadb' driver, so
config('database.default') === 'mysql' checks silently misclassified
MariaDB as the non-mysql (postgres) branch.
- Add App\Util\Database\DatabaseDriver with isMysqlLike()/isPgsql()
plus db_is_mysql_like()/db_is_pgsql() global helpers.
- Route all database.default driver checks through the helpers so
MySQL and MariaDB are treated as one group.
- Use '' (not null) for share/compose caption+rendered, valid whether
the column is nullable or NOT NULL (it is NOT NULL on MySQL/MariaDB).
- Guard pgsql strtolower() in registration against missing fields.
- Scope CustomEmoji::duplicateShortcodes to the grouped column for
Postgres GROUP BY validity.
- Remove stale Postgres guard in status:dedup; use havingRaw for
cross-driver HAVING.
queueDelivery() runs synchronously from the v1 follow/unfollow endpoints
(via Helpers::sendSignedObject), which commit local state before delivery
and have no try/catch. After the Http::send() rewrite, a ConnectionException
from a momentarily-unreachable remote was rethrown out of queueDelivery(),
turning a best-effort delivery into a 500 for the user after the follow/
unfollow was already persisted. For unfollows, a retry then hit the
isFollowing==false branch and never re-sent the Undo, diverging state.
Treat transport failures (ConnectionException) as best-effort on this
single-delivery path: log, record host health, and return without
propagating. Other exception types (invalid sender/destination, signing,
serialization) still throw, matching pre-rewrite precondition behavior.
Also widen SendUpdateActor's per-user catch from HttpException to Throwable
so a single bad host no longer aborts a fleet-wide actor update (the old
HttpException catch is dead for ConnectionException/invalid-destination).
cropPhoto() used coverDown(1080, 1920), which only downscales, so an
undersized crop was stored at its original size (e.g. 110x196). Remote
servers then dropped the image story for not matching expected story
dimensions while videos federated. Use cover() so small crops are scaled
up to fill the canvas.
Note: the complementary remote-side validator relaxation (StoryFetch
validateImageFile) is tracked separately and not included here.
Notification status hydration compared item_type strictly against
Status::class (App\Models\Status). Rows created before the App\ ->
App\Models\ namespace migration store the legacy 'App\Status' morph-map
alias, so the comparison failed and favourite/comment/mention
notifications came back with no attached status. The web UI filters those
out client-side but keeps paginating (response never empty), leaving the
infinite-scroll loader spinning forever.
- NotificationTransformer + Mastodon NotificationTransformer: match both
the legacy alias and the current FQCN when hydrating status.
- NotificationService::buildNotification: same alias-aware deleted-item guard.
- NotificationService::getMaxPage/getMinPage: filter out unrenderable
notifications (status-type without a hydrated status) so the endpoint
never returns rows the UI discards, fixing pagination termination; warn
on unexpected notification types.
- Tests for transformer hydration (legacy + current), renderable filtering,
the unexpected-type warning, and pagination termination.
original_sha256 is the pre-optimization upload hash and can never match the
optimized local file, so comparing against it is not a valid integrity
check for migration. Drop the --verify-sha256 option and the checksum step
entirely; verify now relies on cloud-object existence and size parity.
The cloud migration failure (stale original_sha256 verify) is resolved, so
the temporary default-on debug is no longer needed. Restore --debug to a
bare, off-by-default flag and remove the debugEnabled() string-parsing
helper.
original_sha256 is the hash of the file as originally uploaded, but the
async optimize pipeline (ImageResize/ImageUpdate) rewrites the local file
in place and never updates that column. Verifying the current local bytes
against it made every optimized image fail with sha256_mismatch, so the
migration reported moved=0 and exited 1.
Add a --verify-sha256 flag (off by default) that gates the checksum step.
By default verify relies on cloud-object existence and size parity, which
is the only signal that actually describes the uploaded copy. Add tests
covering both the default (migrates) and opt-in (fails) paths.
The scheduled command only surfaced 'exit code 1' with no cause. Add
structured Log::error entries at every failure path (cloud disk
unresolvable/unconfigured, cloud storage disabled, invalid --before-id,
candidate fetch failure, per-media failure with full context, and a
run-level summary when failures occur).
Also make --debug default to true (now a valued option) so production
runs emit verbose routing detail while this is investigated; pass
--debug=false to silence.
apiV1Add passed an absolute path to FFMpeg::open(), which laravel-ffmpeg
resolves relative to the local disk root, producing a doubled path and an
ffprobe 'Unable to probe / No such file or directory' failure.
Open the file via the disk instead: FFMpeg::fromDisk('local')->open($path)
for local storage, and an on-demand local disk rooted at the temp dir for
cloud storage.
Adds feature tests that pin the disk-relative path resolution.
publishNext() throws ValidationException::withMessages() for invalid
story overlays (bad text/hashtag/mention/url/type), but the enclosing
catch (\Exception $e) caught it too, since ValidationException extends
Exception. Clients got a generic 500 "Failed to create story" instead
of the intended 422 with field-level errors, and the log was flooded
with ordinary user-input validation failures logged as server errors.
Catch ValidationException separately, roll back, and rethrow it so
Laravel's exception handler renders the normal 422 response.
store() and update() open a manual DB::beginTransaction() but had early
return paths (duplicate keyword, keyword not found, per-filter keyword
limit) that hit neither DB::commit() nor the catch block's rollback,
leaving the transaction open past the end of the request. Add the
missing DB::rollBack() call on each early-exit branch, matching the
manual-transaction pattern used elsewhere in the app.
and notification routing
All 15 queues previously ran through one auto-balanced supervisor.
Horizon's `balance: auto` does not honor queue array order for
priority, so despite queue names implying priority ('high' vs 'low'),
a burst on any one queue could starve any other sharing that
supervisor - e.g. a burst of mmo (image/video optimization, 23
dispatch sites, CPU/IO heavy) could delay high-queue DM/follow
delivery just as easily as it could delay low-queue background work.
Split into 4 supervisors grouped by actual job characteristics
(checked via grep across every ->onQueue() call site, not guessed):
- supervisor-priority: high, inbox, pushnotify, follow, default,
shared - user-facing federation/DM/notification delivery.
- supervisor-fanout: feed, story, groups - bursty timeline/story
fanout writes triggered by posts, likes, and follows.
- supervisor-media: mmo - image/video optimize/resize/thumbnail.
Runs a fixed worker pool (balance: false) instead of auto-scaling,
so it can't claim workers away from the other pools under load.
- supervisor-background: low, delete, adelete, move, intbg - imports,
crawling, account deletion/migration; not time-sensitive.
Moved the shared supervisor shape into `defaults` (keyed per
supervisor name, per Horizon's own merge behavior) so `environments`
only needs to override what actually differs, instead of each
environment fully redefining supervisor-1 from scratch. Existing env
vars (HORIZON_MAX_PROCESSES, HORIZON_MIN_PROCESSES,
HORIZON_BALANCE_STRATEGY, HORIZON_SUPERVISOR_*) keep governing the
priority supervisor for continuity with existing deployments; the
three new supervisors get their own HORIZON_*_MAX_PROCESSES vars
with conservative defaults.
Also:
- Added balanceCooldown: 3 explicitly (previously relied on
SupervisorOptions' own constructor default of the same value -
behavior is unchanged, just no longer implicit).
- Wired LongWaitDetected notification routing
(Horizon::routeMailNotificationsTo/routeSlackNotificationsTo) to
new optional config('horizon.notification_routing') keys, sourced
from env vars. Previously these were hardcoded, commented-out
examples with nowhere to actually alert on the `waits` thresholds
already configured below.
Verified by actually starting `php artisan horizon` and inspecting
`horizon:supervisors`: all 4 supervisors registered with exactly the
intended queues, supervisor-media correctly running fixed (non-auto)
balancing. Cross-checked every ->onQueue() call site in app/ against
the new supervisor queue lists - exact match, no queue dropped or
duplicated. Full test suite (715/715) and Larastan clean.
Aligns with the app's dominant convention (171 static Str:: calls vs
24 Str::of() chains). Uses Str::afterLast() for the repeated
"segment after last slash" pattern, Str::matchAll() where a
Collection return is needed, and native explode()/substr() where a
plain array/string suffices.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Aligns with the app's dominant convention (413 now()/today() call
sites vs 12 Carbon::now()). Carbon::parse() calls are untouched since
they parse arbitrary date strings, not current-time access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Repair accounts whose storage counter drifted before the self-heal logic
existed (#7169). A data migration dispatches RecalculateAllUserStoragePipeline
to the low queue so the deploy is not blocked while every user is recomputed
from source. The job is unique and idempotent, so re-runs are harmless.
- RecalculateAllUserStoragePipeline: chunked recalc of all active users
- Migration dispatches the job (no inline heavy work during deploy)
- Test covers bulk recalculation from actual media
UserStorageService::get() now recalculates from source when the cached
counter is missing or older than STALE_AFTER_HOURS, instead of returning a
possibly-inflated cached value. This is what unblocks a user stuck at the
account size limit: the limit check on their next upload attempt reads the
freshly recalculated real usage rather than the drifted value (#7169).
The upload flow reads get() and enforces the limit BEFORE the write-path
heal runs, so a blocked user could never self-heal via upload/delete alone.
Healing on read closes that gap and makes the scheduled reconciler a
belt-and-suspenders safety net rather than a requirement.
A fresh counter is still trusted as-is (no per-read SUM). Adds tests for the
stale-get recompute and fresh-get trust paths.
Replace Auth::check() with $request->user() !== null in confirmEmail(),
which already has the request in scope, and drop the now-unused Auth
facade import. Resolves the 2 remaining project-wide larastan errors.
Make increaseStorageUsed/decrementStorageUsed recalculate from source when
the cached counter is older than STALE_AFTER_HOURS (168h) or never
calculated, so an affected user is corrected the next time they upload or
delete without waiting for the nightly reconciler. Callers save/delete the
media row before calling these, so the from-source recalc already reflects
the change and the incremental delta is skipped on the recalc path.
- Add UserStorageService::STALE_AFTER_HOURS and isStale() helper (no extra
query: reads the already-loaded model), with defensive Carbon parsing
- Cast users.storage_used_updated_at to datetime so freshness comparisons
work on a Carbon instance
- Add tests for stale/fresh/never-calculated increase and decrement paths
users.storage_used only ever grew: uploads incremented it but no deletion
path decremented it, so users hit the account size limit even when their
real media usage was well below it.
- Decrement storage_used in MediaDeletePipeline when media is removed
- Add UserStorageService::increaseStorageUsed / decrementStorageUsed as the
fast, symmetric hot-path counter updates (floor-based, clamped at zero)
- Refactor the 6 upload call sites to use increaseStorageUsed instead of
duplicated inline writes (also fixes ceil/floor drift vs the reconciler)
- Add (user_id, size) covering index so per-user SUM(size) is not a full
table scan (INPLACE/LOCK=NONE, skipped on sqlite)
- Add user:storage:recalculate command to repair affected accounts, with a
daily --stale=168 scheduled reconciler to correct any drift
- Add regression tests for the pipeline and UserStorageService