279 lines
10 KiB
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;
|
|
}
|
|
} |