- Register /up health route and point the web container healthcheck at it
- Add explicit healthcheck to the app service with a boot-aware start period
- Use healthcheck-horizon helper and round out the scheduler healthcheck
- Pin PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 for baked-image code on all services
- Document TRUSTED_PROXY alongside TRUST_PROXIES, default to local
- Raise Caddy max_header_size to 64KiB via a baked global Caddyfile
A member count of zero was the only guard on the local and network warms,
and a warm that finds no eligible posts leaves the count at zero, so a new
instance, or one whose network feed has aged out, ran the full candidate
query on every single timeline request. Both warms now record an empty
outcome and both read paths consult that record as well as the count, so
the query runs once per hour instead of once per request. Any write to
either feed puts the warm straight back in play.
The lock closures return a value as well, so a request that lost the lock
is distinguishable from one that completed a warm; both used to come back
as null.
The feed is ranked by created_at, so the lowest id in a window is not the
member at the bottom of it: a remote post gets its id when we first store
it, not when it was published, so it can be the oldest id and the newest
post at once. The empty-page branch picked that numeric minimum, which
handed the client a cursor past everything between the remote post and
the real bottom of the window.
The cursor is now the display-order oldest member the request fetched. A
min_id client gets a prev cursor and no next, because it asked for newer
content and an older cursor points it at posts it already has. An empty
fetched window emits no Link at all, which is the end-of-feed signal;
previously the whole branch was unreachable because an empty window
returned early with no headers.
With boosts switched off, which is the default, a window of 51 members
that happens to be all boosts produced an empty page and stopped. One
followed account that mostly boosts is enough to do that indefinitely.
The same shape happens behind a muted account's run of posts or a block
of members that were just pruned.
The read now takes up to three further windows while the page is empty
and at least one member was dropped as unrenderable, as a boost, or by a
viewer filter. A page filtered out by the viewer's own rules does not
continue: they asked for that. Direction follows the request: a min_id
read deepens the same bounded window, because Redis enforces the floor
inside the query and walking older would hand a polling client posts it
already has, while any other read walks older from the last member it saw.
The Link cursor is derived from every member the request fetched rather
than the last pass alone, so it cannot point back into a block the client
has already walked.
A member whose status has been deleted, or which cannot be resolved for
any other reason, was dropped from the page and left in the sorted set.
The feed then went on reporting hundreds of members while serving an
empty page, and nothing in the system ever removed them. The hydration
pass now removes such a member as it finds it and logs once per request
when it pruned anything.
Removing the last member makes Redis drop the set, so the feed becomes
genuinely absent and is rebuilt in the same request. That cannot loop:
the key is gone, so the branch is unreachable a second time.
Drops are also counted by reason now, because the reason decides what the
response is allowed to do. A page the viewer explicitly filtered out is
not a defect to recover from, so it still prunes nothing and runs no
query.
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.
A feed with nothing in it and a feed that was never built look the same
from Redis: neither has a key. That is why the read path could not tell
"nothing to show" from "needs building", and why the only bookkeeping it
had was a 300-second marker written before the outcome was known and
evicted by nothing.
The trait now carries an empty-warm guard keyed on the resolved feed key,
holding the timestamp at which it lapses with a matching cache TTL. It is
written only by a warm that published nothing and resolved every
candidate it found: a candidate that would not resolve means the warm did
not finish, so the feed stays eligible and the failure is logged. A
forced warm never consults it.
Eviction is automatic for the instance-wide feeds through setAdd, and
explicit for home feeds, which have no key for a write to reach: the
author's own post, a followed local or remote account posting, a new
follow, and a followed hashtag getting a post all clear it. None of them
creates the feed key, so a follower who has never loaded a timeline still
does not get one.
The warm scoped candidates to statuses newer than a six-month-old
snowflake and excluded boosts outright. Those are reasonable defaults
for the common case, but they were also the definition of "this feed is
empty": a viewer whose follows are dormant, or who follows only accounts
that boost, got no candidates and therefore no feed key, and because
every insert path is gated on that key existing, nothing could ever
repair it.
The candidate selection is now a parameterised pass. The first pass is
unchanged, including the per-account fairness that stops a prolific
account crowding out a capped feed. Only when it publishes nothing does
a second pass run, dropping the id floor, admitting shares, and
re-reading a following list that resolved to the viewer alone.
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.