Files
SkinbaseNova/app/Console/Commands/ReconcileArtworkSearchIndexCommand.php

279 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Meilisearch\Client as MeilisearchClient;
final class ReconcileArtworkSearchIndexCommand extends Command
{
protected $signature = 'artworks:search-reconcile
{--id=* : Specific artwork IDs to inspect instead of scanning the full catalog}
{--after-id=0 : Resume scanning after this artwork id in the chosen sort direction}
{--chunk=200 : Number of artworks per chunk}
{--limit=0 : Stop after this many artworks (0 = no limit)}
{--recent-minutes=0 : Only inspect artworks touched recently by created_at, updated_at, or published_at}
{--reverse : Process highest artwork ids first}
{--repair : Apply fixes instead of reporting only}
{--queue : When repairing, dispatch queue jobs instead of writing directly to Meilisearch}
{--remove-unexpected : Remove live documents for artworks that should not be indexed}
{--no-cache-bump : Skip bumping explore cache version after repairs}}';
protected $description = 'Audit the artwork Meilisearch index against the database and repair missing or stale documents.';
public function handle(MeilisearchClient $client): int
{
$chunk = max(1, (int) $this->option('chunk'));
$limit = max(0, (int) $this->option('limit'));
$afterId = max(0, (int) $this->option('after-id'));
$recentMinutes = max(0, (int) $this->option('recent-minutes'));
$ids = array_values(array_unique(array_filter(array_map('intval', (array) $this->option('id')), static fn (int $id): bool => $id > 0)));
$reverse = (bool) $this->option('reverse');
$repair = (bool) $this->option('repair');
$queue = (bool) $this->option('queue');
$removeUnexpected = (bool) $this->option('remove-unexpected');
$bumpCache = ! (bool) $this->option('no-cache-bump');
if ($queue && ! $repair) {
$this->error('The --queue option requires --repair.');
return self::FAILURE;
}
$query = Artwork::query()
->withoutGlobalScopes()
->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat'])
->when($afterId > 0, function ($builder) use ($afterId, $reverse): void {
$builder->where('id', $reverse ? '<' : '>', $afterId);
})
->orderBy('id', $reverse ? 'desc' : 'asc');
if ($ids === [] && $recentMinutes > 0) {
$cutoff = Carbon::now()->subMinutes($recentMinutes);
$query->where(function ($builder) use ($cutoff): void {
$builder->where('created_at', '>=', $cutoff)
->orWhere('updated_at', '>=', $cutoff)
->orWhere('published_at', '>=', $cutoff);
});
}
if ($ids !== []) {
$query->whereIn('id', $ids);
}
$uncappedTotal = (clone $query)->count();
if ($limit > 0) {
$query->limit($limit);
}
$total = $limit > 0 ? min($limit, $uncappedTotal) : $uncappedTotal;
if ($total === 0) {
$this->warn('No artworks matched the reconcile query.');
return self::SUCCESS;
}
$this->info(sprintf(
'%sReconciling %d artwork(s)%s%s%s%s.',
$repair ? '[REPAIR] ' : '[REPORT] ',
$total,
$ids !== [] ? ' for selected ids' : '',
$recentMinutes > 0 && $ids === [] ? sprintf(' touched in the last %d minute(s)', $recentMinutes) : '',
$reverse ? ' newest first' : '',
$queue ? ' using queued repair jobs' : '',
));
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$stats = [
'processed' => 0,
'ok' => 0,
'missing' => 0,
'stale' => 0,
'unexpected' => 0,
'repaired' => 0,
'failed' => 0,
];
$indexName = null;
$chunkMethod = $reverse ? 'chunkByIdDesc' : 'chunkById';
$query->{$chunkMethod}($chunk, function ($artworks) use ($client, $repair, $queue, $removeUnexpected, $bar, &$stats, &$indexName): void {
foreach ($artworks as $artwork) {
$stats['processed']++;
$indexName ??= $artwork->searchableAs();
$artworkId = (int) $artwork->id;
$eligible = $this->shouldBeIndexed($artwork);
$generatedDocument = $eligible ? $artwork->toSearchableArray() : null;
$liveDocument = null;
$liveMissing = false;
try {
$liveDocument = $client->index($indexName)->getDocument($artworkId);
} catch (\Throwable $exception) {
if ($this->isMissingDocumentError($exception)) {
$liveMissing = true;
} else {
$stats['failed']++;
$bar->clear();
$this->line(sprintf(' <error>error</error> #%d %s', $artworkId, $exception->getMessage()));
$bar->display();
$bar->advance();
continue;
}
}
$status = 'ok';
if ($eligible) {
if ($liveMissing) {
$status = 'missing';
$stats['missing']++;
} elseif (! $this->documentsMatch($generatedDocument, $liveDocument)) {
$status = 'stale';
$stats['stale']++;
} else {
$stats['ok']++;
}
} else {
if (! $liveMissing) {
$status = 'unexpected';
$stats['unexpected']++;
} else {
$stats['ok']++;
}
}
if ($status !== 'ok') {
$bar->clear();
$this->line(sprintf(' <comment>%s</comment> #%d %s', $status, $artworkId, (string) ($artwork->slug ?? '')));
$bar->display();
}
if ($repair) {
try {
if (in_array($status, ['missing', 'stale'], true)) {
$this->repairIndexDocument($client, $artwork, $generatedDocument ?? [], $queue);
$stats['repaired']++;
} elseif ($status === 'unexpected' && $removeUnexpected) {
$this->repairUnexpectedDocument($client, $artworkId, $indexName, $queue);
$stats['repaired']++;
}
} catch (\Throwable $exception) {
$stats['failed']++;
$bar->clear();
$this->line(sprintf(' <error>repair failed</error> #%d %s', $artworkId, $exception->getMessage()));
$bar->display();
}
}
$bar->advance();
}
}, 'id');
$bar->finish();
$this->newLine(2);
if ($repair && $stats['repaired'] > 0 && $bumpCache) {
$newVersion = ((int) Cache::get('explore.cache.version', 1)) + 1;
Cache::forever('explore.cache.version', $newVersion);
$this->line("Explore cache version bumped to {$newVersion}.");
}
$this->table(
['processed', 'ok', 'missing', 'stale', 'unexpected', 'repaired', 'failed'],
[[
$stats['processed'],
$stats['ok'],
$stats['missing'],
$stats['stale'],
$stats['unexpected'],
$stats['repaired'],
$stats['failed'],
]]
);
if (! $repair) {
$this->line('Run again with --repair to fix missing/stale documents directly.');
} elseif (! $removeUnexpected && $stats['unexpected'] > 0) {
$this->line('Unexpected live documents were only reported. Re-run with --remove-unexpected to delete them.');
}
return $stats['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function shouldBeIndexed(Artwork $artwork): bool
{
return (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null && $artwork->published_at->lte(Carbon::now()) && $artwork->deleted_at === null);
}
/**
* @param array<string, mixed> $generatedDocument
* @param mixed $liveDocument
*/
private function documentsMatch(array $generatedDocument, mixed $liveDocument): bool
{
if (! is_array($liveDocument)) {
return false;
}
return $this->normalizeForComparison($generatedDocument) === $this->normalizeForComparison($liveDocument);
}
/**
* @param array<string, mixed> $document
*/
private function repairIndexDocument(MeilisearchClient $client, Artwork $artwork, array $document, bool $queue): void
{
if ($queue) {
IndexArtworkJob::dispatch((int) $artwork->id);
return;
}
$client->index($artwork->searchableAs())->addDocuments([$document]);
}
private function repairUnexpectedDocument(MeilisearchClient $client, int $artworkId, string $indexName, bool $queue): void
{
if ($queue) {
DeleteArtworkFromIndexJob::dispatch($artworkId);
return;
}
$client->index($indexName)->deleteDocument($artworkId);
}
private function isMissingDocumentError(\Throwable $exception): bool
{
return str_contains(strtolower($exception->getMessage()), 'not found');
}
/**
* @param array<string, mixed> $document
* @return array<string, mixed>
*/
private function normalizeForComparison(array $document): array
{
$normalized = Arr::sortRecursive($document);
unset($normalized['_formatted']);
return $normalized;
}
}