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

305 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
final class InspectArtworkSearchIndexCommand extends Command
{
protected $signature = 'artworks:search-inspect
{artwork_id? : The artwork ID to inspect}
{--index= : Override the Meilisearch index name}
{--generated-only : Only print the locally generated search document}
{--live-only : Only print the live document fetched from Meilisearch}
{--json : Print the inspection payload as raw JSON}';
protected $description = 'Inspect the generated Scout payload and live Meilisearch document for a single artwork.';
public function handle(MeilisearchClient $client): int
{
if ($this->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<string, mixed>
*/
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<string, string>
*/
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<string, mixed> $inspection
* @return list<string>
*/
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<string, mixed> $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;
}
}