option('generated-only') && $this->option('live-only')) { $this->error('Use either --generated-only or --live-only, not both together.'); return self::FAILURE; } $artworkId = $this->resolveArtworkId(); if ($artworkId === null) { $this->error('An artwork ID is required.'); return self::FAILURE; } $artwork = Artwork::query() ->with(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']) ->find($artworkId); if ($artwork === null && ! $this->option('live-only')) { $this->error("Artwork #{$artworkId} was not found."); return self::FAILURE; } $indexName = $this->resolveIndexName($artwork); $inspection = [ 'artwork_id' => $artworkId, 'index' => $indexName, 'queue_runtime' => $this->queueRuntimeSummary(), 'artwork' => $artwork ? $this->artworkSummary($artwork) : null, 'generated_document' => null, 'live_document' => null, 'documents_match' => null, 'live_fetch_error' => null, 'diagnosis' => [], ]; if (! $this->option('live-only') && $artwork !== null) { $inspection['generated_document'] = $artwork->toSearchableArray(); } if (! $this->option('generated-only')) { try { $inspection['live_document'] = $client->index($indexName)->getDocument($artworkId); } catch (\Throwable $exception) { $inspection['live_fetch_error'] = $exception->getMessage(); } } if (is_array($inspection['generated_document']) && is_array($inspection['live_document'])) { $inspection['documents_match'] = $this->normalizeForComparison($inspection['generated_document']) === $this->normalizeForComparison($inspection['live_document']); } $inspection['diagnosis'] = $this->buildDiagnosis($artwork, $inspection); $this->renderInspection($inspection); if ($inspection['generated_document'] === null && $inspection['live_document'] === null) { return self::FAILURE; } return self::SUCCESS; } private function resolveArtworkId(): ?int { $argument = $this->argument('artwork_id'); if ($argument !== null && $argument !== '') { return max(1, (int) $argument); } if (! $this->input->isInteractive()) { return null; } $answer = $this->ask('Artwork ID'); if ($answer === null || trim($answer) === '') { return null; } return max(1, (int) $answer); } private function resolveIndexName(?Artwork $artwork): string { $override = trim((string) $this->option('index')); if ($override !== '') { return $override; } if ($artwork !== null) { return $artwork->searchableAs(); } return (string) config('scout.prefix', '') . 'artworks'; } /** * @return array */ private function artworkSummary(Artwork $artwork): array { return [ 'id' => (int) $artwork->id, 'title' => (string) ($artwork->title ?? ''), 'slug' => (string) ($artwork->slug ?? ''), 'is_public' => (bool) $artwork->is_public, 'is_approved' => (bool) $artwork->is_approved, 'published_at' => $artwork->published_at?->toIso8601String(), 'should_be_indexed' => (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null), 'searchable_index' => $artwork->searchableAs(), ]; } /** * @return array */ private function queueRuntimeSummary(): array { return [ 'queue_default_connection' => (string) config('queue.default', 'sync'), 'scout_queue_connection' => (string) config('scout.queue.connection', (string) config('queue.default', 'sync')), 'scout_queue_name' => (string) config('scout.queue.queue', 'default'), ]; } /** * @param array $inspection * @return list */ private function buildDiagnosis(?Artwork $artwork, array $inspection): array { $messages = []; $queueDefault = (string) data_get($inspection, 'queue_runtime.queue_default_connection', 'sync'); $scoutQueueConnection = (string) data_get($inspection, 'queue_runtime.scout_queue_connection', $queueDefault); $scoutQueueName = (string) data_get($inspection, 'queue_runtime.scout_queue_name', 'default'); if ($artwork === null) { $messages[] = 'Artwork row was not found locally, so only a direct live-index check was possible.'; return $messages; } $shouldBeIndexed = (bool) ($artwork->is_public && $artwork->is_approved && $artwork->published_at !== null); if (! $shouldBeIndexed) { $messages[] = 'This artwork should not exist in Meilisearch right now because it is not simultaneously public, approved, and published.'; } if (is_string($inspection['live_fetch_error'] ?? null) && str_contains(strtolower((string) $inspection['live_fetch_error']), 'not found')) { $messages[] = 'The live Meilisearch document is missing from the inspected index.'; if ($shouldBeIndexed) { $messages[] = 'That usually means one of three things: the artwork has not been indexed yet, the Scout sync worker has not processed the job, or you are inspecting the wrong index name/prefix.'; if ($scoutQueueConnection !== $queueDefault) { $messages[] = sprintf( 'This runtime is using queue.default=%s but Scout sync uses scout.queue.connection=%s on queue=%s. If workers only consume %s, Meilisearch updates will never be processed.', $queueDefault, $scoutQueueConnection, $scoutQueueName, $queueDefault, ); if ($scoutQueueConnection === 'database') { $messages[] = sprintf( 'In this configuration, artwork indexing writes are likely sitting on the database queue. Either run a worker for that backend, for example: php artisan queue:work database --queue=%s, or align SCOUT_QUEUE_CONNECTION with your main queue backend.', $scoutQueueName, ); } } else { $messages[] = sprintf( 'Scout is configured to use queue connection %s and queue name %s. Make sure at least one worker actively consumes that exact queue.', $scoutQueueConnection, $scoutQueueName, ); } $messages[] = 'If this artwork should be searchable now, requeue it with: php artisan artworks:search-reindex-recent or run a full rebuild with: php artisan artworks:search-rebuild'; } } if (($inspection['documents_match'] ?? null) === false) { $messages[] = 'The local generated document and the live Meilisearch document differ, so the live index is stale or from a different schema/version.'; } if ($messages === []) { $messages[] = 'No obvious indexing problem was detected from this inspection output.'; } return $messages; } /** * @param array $inspection */ private function renderInspection(array $inspection): void { $jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; if ((bool) $this->option('json')) { $this->line((string) json_encode($inspection, $jsonFlags)); return; } $this->info(sprintf( 'Artwork search inspect — artwork #%d, index %s', (int) $inspection['artwork_id'], (string) $inspection['index'], )); $this->newLine(); if (is_array($inspection['artwork'])) { $this->comment('Artwork'); $this->line((string) json_encode($inspection['artwork'], $jsonFlags)); $this->newLine(); } if (is_array($inspection['queue_runtime'])) { $this->comment('Queue runtime'); $this->line((string) json_encode($inspection['queue_runtime'], $jsonFlags)); $this->newLine(); } if ($inspection['documents_match'] !== null) { $this->line('Generated/live document match: ' . ($inspection['documents_match'] ? 'yes' : 'no')); $this->newLine(); } if (is_array($inspection['diagnosis']) && $inspection['diagnosis'] !== []) { $this->comment('Diagnosis'); foreach ($inspection['diagnosis'] as $message) { $this->line('- ' . $message); } $this->newLine(); } if (is_array($inspection['generated_document'])) { $this->comment('Generated search document'); $this->line((string) json_encode($inspection['generated_document'], $jsonFlags)); $this->newLine(); } if (is_array($inspection['live_document'])) { $this->comment('Live Meilisearch document'); $this->line((string) json_encode($inspection['live_document'], $jsonFlags)); $this->newLine(); } if (is_string($inspection['live_fetch_error']) && $inspection['live_fetch_error'] !== '') { $this->warn('Live document fetch failed: ' . $inspection['live_fetch_error']); } } /** * @param mixed $value * @return mixed */ private function normalizeForComparison(mixed $value): mixed { if (! is_array($value)) { return $value; } foreach ($value as $key => $item) { $value[$key] = $this->normalizeForComparison($item); } if (array_is_list($value)) { return $value; } ksort($value); return $value; } }