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.
511 lines
18 KiB
PHP
511 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Internal;
|
|
|
|
use App\Models\Follower;
|
|
use App\Models\Status;
|
|
use App\Models\User;
|
|
use App\Models\UserDomainBlock;
|
|
use App\Services\AdminShadowFilterService;
|
|
use App\Services\HomeTimelineService;
|
|
use App\Services\InstanceService;
|
|
use App\Services\NetworkTimelineService;
|
|
use App\Services\PublicTimelineService;
|
|
use App\Services\SnowflakeService;
|
|
use App\Services\UserFilterService;
|
|
use Illuminate\Console\Attributes\Description;
|
|
use Illuminate\Console\Attributes\Signature;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
#[Signature('timeline:parity-check {--feed=all : Which feed to check: home|local|network|all} {--window=400 : How many top entries to compare per feed} {--home-samples=5 : How many random home timelines to sample} {--json : Emit a machine-readable JSON report instead of a table}')]
|
|
#[Description('READ-ONLY diagnostic. Compares the cached timeline read path (Redis ZSET) against the uncached DB keyset read path for the network, local/public, and a random sample of home timelines, reporting missing/extra/out-of-order posts and gaps.')]
|
|
class TimelineParityCheck extends Command
|
|
{
|
|
/**
|
|
* Status types eligible for every timeline, shared by all three
|
|
* warmCache definitions in the timeline services.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const STATUS_TYPES = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
|
|
|
|
public function handle(): int
|
|
{
|
|
$feed = (string) $this->option('feed');
|
|
if (! in_array($feed, ['home', 'local', 'network', 'all'], true)) {
|
|
$this->error("Invalid --feed value '{$feed}'. Use home|local|network|all.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$window = max(1, (int) $this->option('window'));
|
|
$homeSamples = max(0, (int) $this->option('home-samples'));
|
|
$asJson = (bool) $this->option('json');
|
|
|
|
/** @var list<array<string, mixed>> $reports */
|
|
$reports = [];
|
|
|
|
if ($feed === 'network' || $feed === 'all') {
|
|
$reports[] = $this->checkNetwork($window);
|
|
}
|
|
|
|
if ($feed === 'local' || $feed === 'all') {
|
|
$reports[] = $this->checkLocal($window);
|
|
}
|
|
|
|
if (($feed === 'home' || $feed === 'all') && $homeSamples > 0) {
|
|
foreach ($this->sampleHomeProfileIds($homeSamples) as $profileId) {
|
|
$reports[] = $this->checkHome((int) $profileId, $window);
|
|
}
|
|
}
|
|
|
|
if ($asJson) {
|
|
$this->line((string) json_encode([
|
|
'generated_at' => now()->toIso8601String(),
|
|
'window' => $window,
|
|
'feeds' => $reports,
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->renderReports($reports, $window);
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Compare the network feed. The ZSET is scored by id but warmCache
|
|
* orders the DB query by created_at, so this feed is the primary
|
|
* suspect for ordering divergence. We mirror warmCache exactly (order
|
|
* by created_at desc) to measure the real-world drift the users hit.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function checkNetwork(int $window): array
|
|
{
|
|
$label = 'network';
|
|
|
|
if (NetworkTimelineService::count() === 0) {
|
|
return $this->coldReport($label);
|
|
}
|
|
|
|
$cached = $this->normalizeIds(NetworkTimelineService::get(0, $window - 1));
|
|
|
|
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
|
|
$filteredDomains = collect(InstanceService::getBannedDomains())
|
|
->merge(InstanceService::getUnlistedDomains())
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$rows = Status::whereNotNull('uri')
|
|
->whereScope('public')
|
|
->when($hideNsfw, fn ($q) => $q->where('is_nsfw', false))
|
|
->whereNull('in_reply_to_id')
|
|
->whereNull('reblog_of_id')
|
|
->whereIn('type', self::STATUS_TYPES)
|
|
->where('created_at', '>', now()->subHours((int) config('instance.timeline.network.max_hours_old')))
|
|
->orderByDesc('created_at')
|
|
->limit($window)
|
|
->get(['id', 'uri']);
|
|
|
|
$uncached = $rows
|
|
->reject(function ($row) use ($filteredDomains) {
|
|
$domain = parse_url((string) $row->uri, PHP_URL_HOST);
|
|
|
|
return in_array($domain, $filteredDomains, true);
|
|
})
|
|
->pluck('id')
|
|
->map(fn ($id) => (string) $id)
|
|
->all();
|
|
|
|
$report = $this->compare($label, $cached, $uncached, $window);
|
|
$report['notes'][] = 'warmCache orders by created_at DESC while the ZSET is scored by id. For federated posts created_at (authored time) and id (local ingest snowflake) are not monotonic, so cached and uncached orderings can legitimately disagree even when the id sets match.';
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* Compare the local/public feed. ZSET score == id and warmCache orders
|
|
* by id, so ordering should agree; set-level drift is the signal here.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function checkLocal(int $window): array
|
|
{
|
|
$label = 'local';
|
|
|
|
if (PublicTimelineService::count() === 0) {
|
|
return $this->coldReport($label);
|
|
}
|
|
|
|
$cached = $this->normalizeIds(PublicTimelineService::get(0, $window - 1));
|
|
|
|
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
|
|
$minId = SnowflakeService::byDate(now()->subDays(90));
|
|
|
|
$rows = Status::where('id', '>', $minId)
|
|
->whereNull(['uri', 'in_reply_to_id', 'reblog_of_id'])
|
|
->when($hideNsfw, fn ($q) => $q->where('is_nsfw', false))
|
|
->whereIn('type', self::STATUS_TYPES)
|
|
->whereScope('public')
|
|
->orderByDesc('id')
|
|
->limit($window)
|
|
->get(['id', 'profile_id']);
|
|
|
|
$uncached = $rows
|
|
->filter(fn ($row) => AdminShadowFilterService::canAddToPublicFeedByProfileId($row->profile_id))
|
|
->pluck('id')
|
|
->map(fn ($id) => (string) $id)
|
|
->all();
|
|
|
|
return $this->compare($label, $cached, $uncached, $window);
|
|
}
|
|
|
|
/**
|
|
* Compare a single home feed. ZSET score == id and warmCache orders by
|
|
* id, so ordering should agree; set-level drift is the signal here.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function checkHome(int $profileId, int $window): array
|
|
{
|
|
$label = "home:{$profileId}";
|
|
|
|
if (HomeTimelineService::count($profileId) === 0) {
|
|
return $this->coldReport($label);
|
|
}
|
|
|
|
$cached = $this->normalizeIds(HomeTimelineService::get($profileId, 0, $window - 1));
|
|
|
|
$following = Follower::whereProfileId($profileId)->pluck('following_id')->push($profileId)->all();
|
|
|
|
$filters = UserFilterService::filters($profileId);
|
|
if ($filters && count($filters)) {
|
|
$following = array_values(array_diff($following, $filters));
|
|
}
|
|
|
|
$minId = SnowflakeService::byDate(now()->subMonths(6));
|
|
$domainBlocks = UserDomainBlock::whereProfileId($profileId)->pluck('domain')->all();
|
|
|
|
$rows = Status::where('id', '>', $minId)
|
|
->whereIn('profile_id', $following)
|
|
->whereNull(['in_reply_to_id', 'reblog_of_id'])
|
|
->whereIn('type', self::STATUS_TYPES)
|
|
->whereIn('visibility', ['public', 'unlisted', 'private'])
|
|
->orderByDesc('id')
|
|
->limit($window)
|
|
->get(['id', 'uri']);
|
|
|
|
$uncached = $rows
|
|
->reject(function ($row) use ($domainBlocks) {
|
|
if (! $domainBlocks || ! $row->uri) {
|
|
return false;
|
|
}
|
|
$domain = strtolower((string) parse_url((string) $row->uri, PHP_URL_HOST));
|
|
|
|
return in_array($domain, $domainBlocks, true);
|
|
})
|
|
->pluck('id')
|
|
->map(fn ($id) => (string) $id)
|
|
->all();
|
|
|
|
return $this->compare($label, $cached, $uncached, $window);
|
|
}
|
|
|
|
/**
|
|
* Randomly pick home profile ids that already have a non-empty home
|
|
* ZSET. Scans a bounded set of existing cache keys (read-only, never
|
|
* warms) and falls back to sampling active local users whose ZSET is
|
|
* already populated. Cold/empty feeds are never warmed.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
private function sampleHomeProfileIds(int $count): array
|
|
{
|
|
$prefix = (string) config('database.redis.options.prefix', '');
|
|
$pattern = $prefix.HomeTimelineService::CACHE_KEY.'*';
|
|
|
|
$found = [];
|
|
$cursor = '0';
|
|
$scanLimit = 5000;
|
|
$scanned = 0;
|
|
|
|
do {
|
|
[$cursor, $keys] = Redis::scan($cursor, ['match' => $pattern, 'count' => 500]);
|
|
if ($keys) {
|
|
foreach ($keys as $key) {
|
|
$scanned++;
|
|
$bare = $prefix !== '' && str_starts_with($key, $prefix)
|
|
? substr($key, strlen($prefix))
|
|
: $key;
|
|
$pid = (int) substr($bare, strlen(HomeTimelineService::CACHE_KEY));
|
|
if ($pid > 0) {
|
|
$found[$pid] = true;
|
|
}
|
|
}
|
|
}
|
|
} while ($cursor !== '0' && $cursor !== 0 && $scanned < $scanLimit);
|
|
|
|
$ids = array_keys($found);
|
|
|
|
if (empty($ids)) {
|
|
// Redis SCAN unavailable (e.g. clustered/managed) or no keys
|
|
// matched. Fall back to active local users and keep only those
|
|
// whose home ZSET is already populated, so nothing is warmed.
|
|
$candidates = User::whereNull('deleted_at')
|
|
->whereNotNull('profile_id')
|
|
->where('status', null)
|
|
->orderByDesc('last_active_at')
|
|
->limit($count * 20)
|
|
->pluck('profile_id')
|
|
->filter(fn ($pid) => HomeTimelineService::count((int) $pid) > 0)
|
|
->values()
|
|
->all();
|
|
|
|
$ids = $candidates;
|
|
}
|
|
|
|
if (empty($ids)) {
|
|
return [];
|
|
}
|
|
|
|
shuffle($ids);
|
|
|
|
return array_slice(array_map('intval', $ids), 0, $count);
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw ZSET read into a list of string ids.
|
|
*
|
|
* @param mixed $raw
|
|
* @return list<string>
|
|
*/
|
|
private function normalizeIds($raw): array
|
|
{
|
|
if (! is_array($raw)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_map(fn ($id) => (string) $id, $raw));
|
|
}
|
|
|
|
/**
|
|
* Build a cold/empty report. We never warm an empty feed on prod.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function coldReport(string $label): array
|
|
{
|
|
return [
|
|
'feed' => $label,
|
|
'verdict' => 'COLD/EMPTY',
|
|
'cached_count' => 0,
|
|
'uncached_count' => 0,
|
|
'overlap' => 0,
|
|
'jaccard' => 0.0,
|
|
'missing_in_cache' => [],
|
|
'extra_in_cache' => [],
|
|
'order_divergence' => null,
|
|
'largest_gap' => 0,
|
|
'notes' => ['Cached ZSET is empty; skipped without warming.'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Compare two ordered id lists and produce the parity report.
|
|
*
|
|
* @param list<string> $cached
|
|
* @param list<string> $uncached
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function compare(string $label, array $cached, array $uncached, int $window): array
|
|
{
|
|
$cachedSet = array_flip($cached);
|
|
$uncachedSet = array_flip($uncached);
|
|
|
|
$overlapIds = array_values(array_intersect($cached, $uncached));
|
|
$overlap = count($overlapIds);
|
|
$unionCount = count($cachedSet + $uncachedSet);
|
|
$jaccard = $unionCount > 0 ? round($overlap / $unionCount, 4) : 0.0;
|
|
|
|
$missingInCache = array_values(array_filter($uncached, fn ($id) => ! isset($cachedSet[$id])));
|
|
$extraInCache = array_values(array_filter($cached, fn ($id) => ! isset($uncachedSet[$id])));
|
|
|
|
$orderDivergence = $this->orderDivergence($cached, $uncached);
|
|
$largestGap = $this->largestGap($uncached, $cachedSet);
|
|
|
|
$verdict = $this->verdict($jaccard, $orderDivergence, count($uncached));
|
|
|
|
return [
|
|
'feed' => $label,
|
|
'verdict' => $verdict,
|
|
'cached_count' => count($cached),
|
|
'uncached_count' => count($uncached),
|
|
'overlap' => $overlap,
|
|
'jaccard' => $jaccard,
|
|
'missing_in_cache' => $missingInCache,
|
|
'missing_in_cache_count' => count($missingInCache),
|
|
'extra_in_cache' => $extraInCache,
|
|
'extra_in_cache_count' => count($extraInCache),
|
|
'order_divergence' => $orderDivergence,
|
|
'largest_gap' => $largestGap,
|
|
'window' => $window,
|
|
'notes' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Measure ordering divergence using only the ids common to both lists,
|
|
* preserving each list's relative order. Reports the first index where
|
|
* the common-id sequences differ and the number of out-of-order pairs.
|
|
*
|
|
* @param list<string> $cached
|
|
* @param list<string> $uncached
|
|
* @return array<string, int|null>
|
|
*/
|
|
private function orderDivergence(array $cached, array $uncached): array
|
|
{
|
|
$cachedSet = array_flip($cached);
|
|
$uncachedSet = array_flip($uncached);
|
|
|
|
$commonCached = array_values(array_filter($cached, fn ($id) => isset($uncachedSet[$id])));
|
|
$commonUncached = array_values(array_filter($uncached, fn ($id) => isset($cachedSet[$id])));
|
|
|
|
$firstDivergentIndex = null;
|
|
$limit = min(count($commonCached), count($commonUncached));
|
|
for ($i = 0; $i < $limit; $i++) {
|
|
if ($commonCached[$i] !== $commonUncached[$i]) {
|
|
$firstDivergentIndex = $i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Count out-of-order pairs: rank each common id by its position in
|
|
// the uncached list, then count inversions in the cached ordering.
|
|
$rank = array_flip($commonUncached);
|
|
$outOfOrderPairs = 0;
|
|
$sequence = array_map(fn ($id) => $rank[$id], $commonCached);
|
|
$n = count($sequence);
|
|
for ($i = 0; $i < $n; $i++) {
|
|
for ($j = $i + 1; $j < $n; $j++) {
|
|
if ($sequence[$i] > $sequence[$j]) {
|
|
$outOfOrderPairs++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'common_count' => $limit,
|
|
'first_divergent_index' => $firstDivergentIndex,
|
|
'out_of_order_pairs' => $outOfOrderPairs,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Largest contiguous run of uncached ids missing from the cache. This
|
|
* surfaces the "jumps around" gaps users report.
|
|
*
|
|
* @param list<string> $uncached
|
|
* @param array<string, int> $cachedSet
|
|
*/
|
|
private function largestGap(array $uncached, array $cachedSet): int
|
|
{
|
|
$largest = 0;
|
|
$current = 0;
|
|
foreach ($uncached as $id) {
|
|
if (isset($cachedSet[$id])) {
|
|
$current = 0;
|
|
|
|
continue;
|
|
}
|
|
$current++;
|
|
if ($current > $largest) {
|
|
$largest = $current;
|
|
}
|
|
}
|
|
|
|
return $largest;
|
|
}
|
|
|
|
/**
|
|
* Verdict thresholds: Jaccard < 0.7 or any out-of-order pairs with a low
|
|
* similarity is a major divergence; Jaccard < 0.9 is minor drift.
|
|
*
|
|
* @param array<string, int|null> $orderDivergence
|
|
*/
|
|
private function verdict(float $jaccard, array $orderDivergence, int $uncachedCount): string
|
|
{
|
|
if ($uncachedCount === 0) {
|
|
return 'NO DB RESULTS';
|
|
}
|
|
|
|
if ($jaccard < 0.7) {
|
|
return 'MAJOR DIVERGENCE';
|
|
}
|
|
|
|
$outOfOrder = (int) ($orderDivergence['out_of_order_pairs'] ?? 0);
|
|
if ($jaccard < 0.9 || $outOfOrder > 0) {
|
|
return 'MINOR DRIFT';
|
|
}
|
|
|
|
return 'OK';
|
|
}
|
|
|
|
/**
|
|
* Render the human-readable report.
|
|
*
|
|
* @param list<array<string, mixed>> $reports
|
|
*/
|
|
private function renderReports(array $reports, int $window): void
|
|
{
|
|
$this->info("Timeline parity check (window={$window}, read-only)");
|
|
$this->newLine();
|
|
|
|
$rows = [];
|
|
foreach ($reports as $r) {
|
|
$order = $r['order_divergence'];
|
|
$orderSummary = $order === null
|
|
? '-'
|
|
: sprintf('firstDiff=%s pairs=%d', $order['first_divergent_index'] ?? '—', $order['out_of_order_pairs'] ?? 0);
|
|
|
|
$rows[] = [
|
|
$r['feed'],
|
|
$r['verdict'],
|
|
$r['cached_count'],
|
|
$r['uncached_count'],
|
|
$r['overlap'],
|
|
$r['jaccard'],
|
|
$r['missing_in_cache_count'] ?? count($r['missing_in_cache']),
|
|
$r['extra_in_cache_count'] ?? count($r['extra_in_cache']),
|
|
$r['largest_gap'],
|
|
$orderSummary,
|
|
];
|
|
}
|
|
|
|
$this->table(
|
|
['Feed', 'Verdict', 'Cached', 'Uncached', 'Overlap', 'Jaccard', 'Missing', 'Extra', 'MaxGap', 'Order'],
|
|
$rows
|
|
);
|
|
|
|
foreach ($reports as $r) {
|
|
if (! empty($r['notes'])) {
|
|
$this->newLine();
|
|
$this->comment("[{$r['feed']}]");
|
|
foreach ($r['notes'] as $note) {
|
|
$this->line(' - '.$note);
|
|
}
|
|
}
|
|
|
|
$missing = $r['missing_in_cache'] ?? [];
|
|
if (! empty($missing)) {
|
|
$sample = array_slice($missing, 0, 10);
|
|
$this->line(' missing_in_cache (first 10): '.implode(', ', $sample));
|
|
}
|
|
}
|
|
}
|
|
}
|