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; } }