The read path had three ways of answering a request whose window came
back empty, and all three could leave a user staring at a blank timeline.
It returned early whenever the set had any members, so a feed full of
unresolvable ids never got looked at. It only attempted a rebuild for a
request that sent no cursor, so a client holding a cursor got nothing on
every retry. And it marked the rebuild as done before knowing whether it
had worked, in a key nothing ever evicted, so a warm that threw
suppressed the next five minutes of attempts, and the throw itself
escaped the synchronous dispatch and turned the response into a 500.
A missing cached set is now rebuilt in-request behind a non-blocking
30-second lock: a reader that loses the lock serves nothing rather than
running a second full warm, one that wins releases it in a finally, and a
warm that throws is logged and leaves the feed eligible for the next
request. The page is then re-read through the same window helper, so the
cursor the client sent survives the rebuild. The rebuild goes 100 members
deep and queues the deepening job only when it filled that depth.
Three existing cases asserted the old bookkeeping rather than the
behaviour it was meant to protect. They keep their 200s and their empty
bodies and now assert the single-warm guarantee directly.
Every cached timeline is trimmed, so a client that persisted a cursor
from before the last trim sends an id whose score sits under every
remaining member. The strict getter correctly finds nothing older, the
endpoint returned an empty page, and the client kept sending the same
dead cursor on every retry. A separate getRankedMaxIdOrRecent getter
leaves getRankedMaxId's strict contract intact: when the window is empty,
the feed still has members and the cursor resolves below the oldest of
them, the newest window is served instead. A cursor at or above the
oldest member is genuine end-of-feed and still answers with an empty page.
Re-derived on staging on top of the non-positive-cursor helpers
(normaliseHomeCursor/fetchHomeWindow), which it extends.
Verified standalone off staging:
HomeCursorRecoveryTest 6 passed; tests/Feature/Timeline + HomeFeed 77 passed.
The home window getter was picked by $request->has('min_id'), so a
request carrying min_id=0 (or a blank min_id) alongside a real max_id
paged against a zero floor, short-circuited inside the service and came
back empty every time. Normalising the values alone would not have
helped, because presence was the mechanism, so the has() check is gone
and the cursor is resolved once into an optional pair of positive ids.
min_id still wins over max_id when both are positive.
Verified standalone off staging:
HomeCursorRecoveryTest 8 passed; tests/Feature/Timeline + HomeFeed 79 passed.
INSTANCE_NETWORK_TIMELINE_CACHE_DROPOFF was read straight into the trim
cap and into the warm's query bound. Left blank it casts to 0, so every
insert trimmed the set back to nothing and the network timeline was
permanently empty, while the warm's limit('') silently compiled to no
bound at all. feedLimit() now falls back to the documented default of
100 for any non-numeric or sub-1 value, warmCache normalises its own
limit, and the three call sites stop passing the raw config value.
The sync_licenses flag in accountUpdateCredentials was parsed with a loose
comparison ($request->input('sync_licenses') == true), so any non-empty value
other than "0" counted as opting in. A client sending sync_licenses=false/no/off
to opt out was treated as opting in, triggering MediaSyncLicensePipeline whenever
the default license also changed. Sibling booleans in the same method already use
$request->boolean(); this brings sync_licenses in line.
Added tests/Feature/Api/AccountSyncLicensesBooleanTest.php covering the opt-out
string, the positive control, and the omitted case. Each uses a fresh array cache
so the pf:settings:mls_recently:{uid} rate-limit key does not suppress a dispatch.
Verification (ddev exec "vendor/bin/pest <test> --compact"):
- Before the fix: 1 failed, 2 passed. The sync_licenses='false' case dispatched
MediaSyncLicensePipeline (assertNotDispatched failed).
- After the fix: 3 passed (6 assertions).
Read and delete paths for user_filters now match both the legacy App\Profile and current App\Models\Profile morph strings, so pre-migration block/mute rows are visible and removable again. Write paths keep the canonical class. Adds a migration that deduplicates then normalizes legacy rows.
Bookmark toggle endpoints validated the status eligibility (no reply/reblog,
allowed scope, media type) before attempting removal, so a bookmark pointing
at a reply, reblog or non-media status could never be deleted — every removal
request 404'd before the delete ran.
Attempt removal first and enforce the create-only gates only when creating a
new bookmark:
- BookmarkController::store() removes an existing bookmark before the gates.
- ApiV1Controller::bookmarkStatus() returns the idempotent bookmarked=true
response for an already-existing bookmark before the gates.
- ApiV1Controller::unbookmarkStatus() drops the create-only gates; it is a pure
removal and keeps the private-follower guard.
Response shapes, the private-post handling and BookmarkService usage are
unchanged.
Test: tests/Feature/BookmarkLegacyRemovalTest.php
Verified red -> green with:
DB_CONNECTION=sqlite DB_DATABASE=:memory: APP_ENV=testing \
REDIS_HOST=ddev-pixelfed-redis CACHE_STORE=redis \
vendor/bin/pest tests/Feature/BookmarkLegacyRemovalTest.php
Red (before fix): 2 failed, 2 passed — legacy removal on a reply (web) and a
reblog (api) both returned 404 instead of 200; the two control cases passed.
Green (after fix): 4 passed (13 assertions).
Regression: tests/Feature/Api/StatusTest.php 12 passed (20 assertions).
Reverts the timeline rebuild, empty-warm, pagination, feed-recovery, unfollow-preservation and network-reconcile changes (PRs #7796, #7797, #7798), restoring the feed services, controllers and pipelines to their prior state and removing the added timeline tests.
Co-authored-by: Ashley <ashley@asheyhull.net>
Prune unresolvable numeric cache members before hydration and walk public
and network windows past a stale block so an eligible post below it is
reached instead of returning an empty page. Home feeds rebuild cold when a
prune empties the cache; public and network feeds advance the recovery
cursor by Redis score so it stands alone on the current sorted-set paging.
Verification: .agents/tasks/pixelfed-timeline-feed-recovery-2026-10-18/verification.md
The home and public/network feeds rank by created_at, but the next/prev Link
headers still took the min and max status id on the page. A remote post can
hold a low id with a recent date, so the id-derived cursor pointed at the
wrong row and the next request re-served posts that were already shown,
making infinite scroll stutter or stall.
Build the cursors from the first and last items in display order instead, so
the next page continues from the oldest post actually shown. Add a regression
test that walks the Link header through a feed whose id order and date order
disagree and asserts every post is served exactly once.
Drop an always-true empty() guard on the home feed Link header that static
analysis flagged as redundant.
Update the fanout and follow-backfill tests to seed a live home feed before
dispatching, since fanout now only appends to feeds that already exist and
skips idle ones. Switch the network null-account test to a real sorted set
instead of a Redis spy so the cached read path actually runs, and force a
deterministic id in the chronological ordering test so it no longer depends
on sub-millisecond insert timing.
A cold home feed warmed synchronously on the request thread but always
returned 206 with an empty body, so a user whose feed warmed to real
content got nothing until a retry, and a user following only post-less
accounts was stuck on 206 indefinitely. The warm completes before the
response, so read the freshly warmed set and return it with 200, or 200
with an empty array when there is genuinely nothing to show.
The home read decided cold-start from the fetched page being empty, so a
min_id poll with nothing new or a max_id past the end forced a warm,
returned 206, and renewed the day-long cold-boot guard on every empty
poll, which could keep a genuinely empty feed from ever warming. Base
cold-start on the set being empty, return 200 for an empty cursor window,
and use a short retry window for the cold-boot guard.
The cached home feed relied entirely on asynchronous removal jobs to
drop muted or blocked accounts, so a backlog or dropped job left their
posts visible until the next rebuild. Filter the hydrated statuses
against the viewer's mutes, blocks and domain blocks at read time,
checking both the status and its reblog author.
When every status in the fetched window was removed by filtering, the
home timeline returned an empty body with no Link header, so clients
treated it as the end of the feed even though older eligible posts
remained. Emit a next cursor past the fetched window in that case.
The cache_check closure returned null, so Cache::remember treated it as a
miss and ran the empty-set check on every request. When the set was empty
concurrent requests each forced a full warmCache and deleted each other's
work. Only warm when the set is empty, and take a short non-blocking lock
so a single request rebuilds while the rest skip.
timelinePublic forced min_id to 1 whenever max_id equalled 0, but a
missing max_id is null and null == 0 is true in PHP, so any request
without max_id discarded a supplied min_id and returned the head of the
feed. Only apply the reset when max_id is actually provided as 0.
Remove the experimental cached_home_timeline/cached_public_timeline
config and the read- and write-side gates across the home, local, and
network timelines, deleting the non-cached query branches so the
Redis-backed path is the only path. Cold-start warming and 206
semantics are preserved.
Also fixes defects found while promoting the paths: unconditional home
feed cache-forget on status update/delete, consistent trim-to-limit
across the three timeline services, network warmCache depth sourced
from cache_dropoff, and the photos_reblogs_only preference now applied
on the home feed. Adds endpoint and service test coverage.