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 #%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(' %s #%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(' repair failed #%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 $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 $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 $document
* @return array
*/
private function normalizeForComparison(array $document): array
{
$normalized = Arr::sortRecursive($document);
unset($normalized['_formatted']);
return $normalized;
}
}