mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
319 lines
10 KiB
PHP
319 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Follower;
|
|
use App\Models\Status;
|
|
use App\Models\UserDomainBlock;
|
|
use App\Services\Concerns\SortedSetTimeline;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Str;
|
|
|
|
class HomeTimelineService
|
|
{
|
|
use SortedSetTimeline;
|
|
|
|
const CACHE_KEY = 'pf:services:timeline:v2:home:';
|
|
|
|
const CACHE_TTL = 1209600;
|
|
|
|
const FEED_LIMIT = 400;
|
|
|
|
const FOLLOWER_FEED_POST_LIMIT = 10;
|
|
|
|
/**
|
|
* How long a home feed stays recorded as genuinely empty. Shorter than
|
|
* the instance-wide feeds because a home feed turns non-empty through
|
|
* other people's actions, which may not reach a gated key.
|
|
*/
|
|
const EMPTY_WARM_INTERVAL = 900;
|
|
|
|
public static function get($id, $start = 0, $stop = 10)
|
|
{
|
|
self::cacheKeyTouch($id);
|
|
|
|
return self::setGet(self::CACHE_KEY.$id, $start, $stop);
|
|
}
|
|
|
|
public static function getRankedMaxId($id, $start = null, $limit = 10)
|
|
{
|
|
self::cacheKeyTouch($id);
|
|
|
|
return self::setRankedMaxId(self::CACHE_KEY.$id, $start, $limit);
|
|
}
|
|
|
|
public static function getRankedMaxIdOrRecent($id, $start = null, $limit = 10)
|
|
{
|
|
self::cacheKeyTouch($id);
|
|
|
|
return self::setRankedMaxIdOrRecent(self::CACHE_KEY.$id, $start, $limit);
|
|
}
|
|
|
|
public static function getRankedMinId($id, $end = null, $limit = 10)
|
|
{
|
|
self::cacheKeyTouch($id);
|
|
|
|
return self::setRankedMinId(self::CACHE_KEY.$id, $end, $limit);
|
|
}
|
|
|
|
public static function add($id, $val)
|
|
{
|
|
$res = self::setAdd(self::CACHE_KEY.$id, $val, self::FEED_LIMIT);
|
|
|
|
self::cacheKeyTouch($id);
|
|
|
|
return $res;
|
|
}
|
|
|
|
public static function rem($id, $val)
|
|
{
|
|
return self::setRem(self::CACHE_KEY.$id, $val);
|
|
}
|
|
|
|
public static function count($id)
|
|
{
|
|
return self::setCount(self::CACHE_KEY.$id);
|
|
}
|
|
|
|
public static function statusScore($statusId)
|
|
{
|
|
return self::scoreForStatus($statusId);
|
|
}
|
|
|
|
public static function cacheKeyExists($id)
|
|
{
|
|
return (bool) Redis::exists(self::CACHE_KEY.$id);
|
|
}
|
|
|
|
public static function cacheKeyTouch($id)
|
|
{
|
|
if (Redis::exists(self::CACHE_KEY.$id)) {
|
|
Redis::expire(self::CACHE_KEY.$id, self::CACHE_TTL);
|
|
}
|
|
}
|
|
|
|
public static function shouldWarmEmpty($id): bool
|
|
{
|
|
return self::setShouldWarmEmpty(self::CACHE_KEY.$id);
|
|
}
|
|
|
|
public static function markEmptyWarmed($id): void
|
|
{
|
|
self::setMarkEmptyWarmed(self::CACHE_KEY.$id, self::EMPTY_WARM_INTERVAL);
|
|
}
|
|
|
|
public static function clearEmptyWarmed($id): void
|
|
{
|
|
self::setClearEmptyWarmed(self::CACHE_KEY.$id);
|
|
}
|
|
|
|
/**
|
|
* Rebuild the cached feed, returning the number of members published.
|
|
*
|
|
* Returns 0 both when nothing was published and when the guard declined
|
|
* to run; the read path only ever forces, so the two cannot collide
|
|
* there, and every other caller treats them the same way.
|
|
*/
|
|
public static function warmCache($id, bool $force = false, int $limit = 100): int
|
|
{
|
|
if (self::count($id) != 0 && $force != true) {
|
|
return 0;
|
|
}
|
|
|
|
$limit = $limit > 0 ? $limit : 100;
|
|
|
|
$following = self::followingForWarm($id);
|
|
$domainBlocks = UserDomainBlock::whereProfileId($id)->pluck('domain')->toArray();
|
|
|
|
$pass = self::publishWarm($id, self::warmCandidates($following, $limit, true, false), $domainBlocks);
|
|
$unresolved = $pass['unresolved'];
|
|
|
|
if ($pass['added'] > 0) {
|
|
self::clearEmptyWarmed($id);
|
|
|
|
return $pass['added'];
|
|
}
|
|
|
|
// Nothing publishable inside the fast path. Before concluding there is
|
|
// nothing to show at all, drop the two assumptions that path makes for
|
|
// speed — that anything worth showing was posted in the last six
|
|
// months, and that a boost is not content — and re-read a following
|
|
// list that resolved to the viewer alone, which is what a list cached
|
|
// before a follow landed looks like.
|
|
if (self::followingIsSelfOnly($following, $id)) {
|
|
Cache::forget('profile:following:'.$id);
|
|
$following = self::followingForWarm($id);
|
|
}
|
|
|
|
$pass = self::publishWarm($id, self::warmCandidates($following, $limit, false, true), $domainBlocks);
|
|
$unresolved += $pass['unresolved'];
|
|
|
|
if ($pass['added'] > 0) {
|
|
self::clearEmptyWarmed($id);
|
|
|
|
return $pass['added'];
|
|
}
|
|
|
|
if ($unresolved > 0) {
|
|
// Candidates were there and could not be resolved, so this warm
|
|
// never established that the feed is empty. Recording it as empty
|
|
// here is how a transient failure becomes a blank timeline the
|
|
// user cannot get out of.
|
|
Log::warning('HomeTimelineService: home warm could not resolve any candidate', [
|
|
'pid' => $id,
|
|
'unresolved' => $unresolved,
|
|
]);
|
|
|
|
return 0;
|
|
}
|
|
|
|
self::markEmptyWarmed($id);
|
|
|
|
return 0;
|
|
}
|
|
|
|
protected static function followingForWarm($id): array
|
|
{
|
|
$following = Cache::remember('profile:following:'.$id, 1209600, function () use ($id) {
|
|
$following = Follower::whereProfileId($id)->pluck('following_id');
|
|
|
|
return $following->push($id)->toArray();
|
|
});
|
|
|
|
$filters = UserFilterService::filters($id);
|
|
|
|
if ($filters && count($filters)) {
|
|
$following = array_diff($following, $filters);
|
|
}
|
|
|
|
return array_values($following);
|
|
}
|
|
|
|
protected static function followingIsSelfOnly(array $following, $id): bool
|
|
{
|
|
return count($following) === 1 && (int) reset($following) === (int) $id;
|
|
}
|
|
|
|
/**
|
|
* The candidate ids for one warm pass.
|
|
*
|
|
* The per-account latest is merged with the newest overall so a prolific
|
|
* account cannot crowd everyone else out of a capped feed.
|
|
*/
|
|
protected static function warmCandidates(array $following, int $limit, bool $windowed, bool $includeShares)
|
|
{
|
|
$types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
|
|
$nullable = ['in_reply_to_id'];
|
|
|
|
if ($includeShares) {
|
|
$types[] = 'share';
|
|
} else {
|
|
$nullable[] = 'reblog_of_id';
|
|
}
|
|
|
|
$minId = SnowflakeService::byDate(now()->subMonths(6));
|
|
|
|
$baseQuery = function () use ($following, $windowed, $minId, $types, $nullable) {
|
|
return Status::when($windowed, function ($q) use ($minId) {
|
|
return $q->where('id', '>', $minId);
|
|
})
|
|
->whereIn('profile_id', $following)
|
|
->whereNull($nullable)
|
|
->whereIn('type', $types)
|
|
->whereIn('visibility', ['public', 'unlisted', 'private']);
|
|
};
|
|
|
|
$latestPerAccount = $baseQuery()
|
|
->select(DB::raw('max(id) as id'))
|
|
->groupBy('profile_id')
|
|
->pluck('id')
|
|
->sortDesc()
|
|
->take($limit)
|
|
->values();
|
|
|
|
$remaining = $limit - $latestPerAccount->count();
|
|
|
|
$recentIds = $remaining > 0
|
|
? $baseQuery()
|
|
->whereNotIn('id', $latestPerAccount->all())
|
|
->orderByDesc('id')
|
|
->limit($remaining)
|
|
->pluck('id')
|
|
: collect();
|
|
|
|
return $latestPerAccount
|
|
->merge($recentIds)
|
|
->unique()
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* Hydrate the candidates into a scratch set and publish it atomically.
|
|
*
|
|
* Returns the number of members published and the number of candidates
|
|
* that could not be resolved at all. The caller needs the two apart: a
|
|
* candidate skipped by a domain block is a decision, while a candidate
|
|
* that would not resolve means the warm did not finish its work.
|
|
*/
|
|
protected static function publishWarm($id, $candidates, array $domainBlocks): array
|
|
{
|
|
$tmpKey = self::CACHE_KEY.$id.':rebuild:'.Str::random(8);
|
|
$added = 0;
|
|
$unresolved = 0;
|
|
|
|
foreach ($candidates as $sid) {
|
|
try {
|
|
$status = StatusService::get($sid, false);
|
|
} catch (\Throwable $e) {
|
|
$unresolved++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (! $status || ! isset($status['account'], $status['url'])) {
|
|
$unresolved++;
|
|
|
|
continue;
|
|
}
|
|
if ($domainBlocks && count($domainBlocks)) {
|
|
$domain = parse_url($status['url'], PHP_URL_HOST);
|
|
if (! is_string($domain) || $domain === '') {
|
|
$unresolved++;
|
|
|
|
continue;
|
|
}
|
|
if (in_array(strtolower($domain), $domainBlocks)) {
|
|
continue;
|
|
}
|
|
}
|
|
$score = ! empty($status['created_at']) ? strtotime($status['created_at']) : false;
|
|
Redis::zadd($tmpKey, $score !== false ? $score : self::snowflakeScore($sid), $sid);
|
|
$added++;
|
|
}
|
|
|
|
if ($added === 0) {
|
|
// The instance-wide feeds clear themselves when a warm finds
|
|
// nothing, because their content is the instance's content. A
|
|
// per-user feed is different: a warm can come up empty because
|
|
// this user's relationships or the statuses behind their feed
|
|
// could not be resolved, and throwing away a feed that was
|
|
// serving content is how a user ends up with a blank timeline.
|
|
Redis::del($tmpKey);
|
|
|
|
return ['added' => 0, 'unresolved' => $unresolved];
|
|
}
|
|
|
|
if ($added > self::FEED_LIMIT) {
|
|
Redis::zremrangebyrank($tmpKey, 0, -(self::FEED_LIMIT + 1));
|
|
}
|
|
|
|
Redis::rename($tmpKey, self::CACHE_KEY.$id);
|
|
Redis::expire(self::CACHE_KEY.$id, self::CACHE_TTL);
|
|
|
|
return ['added' => $added, 'unresolved' => $unresolved];
|
|
}
|
|
}
|