Wire admin studio SSR and search infrastructure
This commit is contained in:
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal file
305
app/Console/Commands/InspectArtworkSearchIndexCommand.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user