Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
349 lines
9.2 KiB
PHP
349 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkEmbedding;
|
|
use App\Services\Vision\ArtworkEmbeddingClient;
|
|
use App\Services\Vision\ArtworkVectorIndexService;
|
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
|
{
|
|
use Dispatchable;
|
|
use InteractsWithQueue;
|
|
use Queueable;
|
|
use SerializesModels;
|
|
|
|
public int $tries = 3;
|
|
|
|
public int $timeout = 20;
|
|
|
|
public function __construct(
|
|
private readonly int $artworkId,
|
|
private readonly ?string $sourceHash = null,
|
|
private readonly bool $force = false,
|
|
) {
|
|
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
|
if ($queue !== '') {
|
|
$this->onQueue($queue);
|
|
}
|
|
}
|
|
|
|
public function backoff(): array
|
|
{
|
|
return [2, 10, 30];
|
|
}
|
|
|
|
public function handle(
|
|
ArtworkEmbeddingClient $client,
|
|
ArtworkVisionImageUrl $imageUrlBuilder,
|
|
ArtworkVectorIndexService $vectors,
|
|
): void {
|
|
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
|
return;
|
|
}
|
|
|
|
$artwork = Artwork::query()
|
|
->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')])
|
|
->find($this->artworkId);
|
|
if (! $artwork) {
|
|
return;
|
|
}
|
|
|
|
$sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? '')));
|
|
if ($sourceHash === '') {
|
|
return;
|
|
}
|
|
|
|
$model = (string) config('recommendations.embedding.model', 'clip');
|
|
$modelVersion = (string) config('recommendations.embedding.model_version', 'v1');
|
|
$algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
|
|
|
if (! $this->force) {
|
|
$existing = ArtworkEmbedding::query()
|
|
->where('artwork_id', $artwork->id)
|
|
->where('model', $model)
|
|
->where('model_version', $modelVersion)
|
|
->first();
|
|
|
|
if (
|
|
$existing
|
|
&& (string) ($existing->source_hash ?? '') === $sourceHash
|
|
&& $this->vectorIndexCoversEmbedding(
|
|
$artwork,
|
|
$existing
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$lockKey = $this->lockKey(
|
|
$artwork->id,
|
|
$model,
|
|
$modelVersion
|
|
);
|
|
|
|
if (! $this->acquireLock($lockKey)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
/*
|
|
* Another worker may have generated or indexed the
|
|
* embedding between our first read and lock acquisition.
|
|
* Re-read both records under the lock.
|
|
*/
|
|
if (! $this->force) {
|
|
$artwork->refresh();
|
|
|
|
$existing = ArtworkEmbedding::query()
|
|
->where('artwork_id', $artwork->id)
|
|
->where('model', $model)
|
|
->where('model_version', $modelVersion)
|
|
->first();
|
|
|
|
if (
|
|
$existing
|
|
&& (string) ($existing->source_hash ?? '')
|
|
=== $sourceHash
|
|
) {
|
|
if (
|
|
$this->vectorIndexCoversEmbedding(
|
|
$artwork,
|
|
$existing
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
$storedVector = $this->decodeStoredVector(
|
|
$existing
|
|
);
|
|
|
|
if ($storedVector !== []) {
|
|
$this->upsertVectorIndex(
|
|
$vectors,
|
|
$artwork,
|
|
$storedVector
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
$imageUrl = $imageUrlBuilder->fromArtwork($artwork);
|
|
if ($imageUrl === null) {
|
|
return;
|
|
}
|
|
|
|
$vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash);
|
|
if ($vector === []) {
|
|
return;
|
|
}
|
|
|
|
$normalized = $this->normalize($vector);
|
|
|
|
ArtworkEmbedding::query()->updateOrCreate(
|
|
[
|
|
'artwork_id' => (int) $artwork->id,
|
|
'model' => $model,
|
|
'model_version' => $modelVersion,
|
|
],
|
|
[
|
|
'algo_version' => $algoVersion,
|
|
'dim' => count($normalized),
|
|
'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR),
|
|
'source_hash' => $sourceHash,
|
|
'is_normalized' => true,
|
|
'generated_at' => now(),
|
|
'meta' => [
|
|
'source' => 'clip',
|
|
'image_variant' => (string) config('vision.image_variant', 'md'),
|
|
],
|
|
]
|
|
);
|
|
|
|
$this->upsertVectorIndex(
|
|
$vectors,
|
|
$artwork,
|
|
$normalized
|
|
);
|
|
} finally {
|
|
$this->releaseLock($lockKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, float> $vector
|
|
*/
|
|
private function upsertVectorIndex(
|
|
ArtworkVectorIndexService $vectors,
|
|
Artwork $artwork,
|
|
array $vector
|
|
): void {
|
|
if (! $vectors->isConfigured()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$vectors->upsertArtworkVector(
|
|
$artwork,
|
|
$vector
|
|
);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
|
|
'artwork_id' => (int) $artwork->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function vectorIndexCoversEmbedding(
|
|
Artwork $artwork,
|
|
ArtworkEmbedding $embedding
|
|
): bool {
|
|
$indexedAt = $artwork->last_vector_indexed_at;
|
|
$generatedAt = $embedding->generated_at;
|
|
|
|
if ($indexedAt === null || $generatedAt === null) {
|
|
return false;
|
|
}
|
|
|
|
return $indexedAt->greaterThanOrEqualTo(
|
|
$generatedAt
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, float>
|
|
*/
|
|
private function decodeStoredVector(
|
|
ArtworkEmbedding $embedding
|
|
): array {
|
|
try {
|
|
$decoded = json_decode(
|
|
(string) $embedding->embedding_json,
|
|
true,
|
|
512,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
|
|
if (! is_array($decoded) || $decoded === []) {
|
|
return [];
|
|
}
|
|
|
|
$vector = [];
|
|
|
|
foreach ($decoded as $value) {
|
|
if (! is_int($value) && ! is_float($value)) {
|
|
return [];
|
|
}
|
|
|
|
$value = (float) $value;
|
|
|
|
if (! is_finite($value)) {
|
|
return [];
|
|
}
|
|
|
|
$vector[] = $value;
|
|
}
|
|
|
|
$count = count($vector);
|
|
|
|
$minDim = max(
|
|
1,
|
|
(int) config(
|
|
'recommendations.embedding.min_dim',
|
|
64
|
|
)
|
|
);
|
|
|
|
$maxDim = max(
|
|
$minDim,
|
|
(int) config(
|
|
'recommendations.embedding.max_dim',
|
|
4096
|
|
)
|
|
);
|
|
|
|
if (
|
|
$count < $minDim
|
|
|| $count > $maxDim
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
(int) $embedding->dim > 0
|
|
&& $count !== (int) $embedding->dim
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
return $vector;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, float> $vector
|
|
* @return array<int, float>
|
|
*/
|
|
private function normalize(array $vector): array
|
|
{
|
|
$sumSquares = 0.0;
|
|
foreach ($vector as $value) {
|
|
$sumSquares += ($value * $value);
|
|
}
|
|
|
|
if ($sumSquares <= 0.0) {
|
|
return $vector;
|
|
}
|
|
|
|
$norm = sqrt($sumSquares);
|
|
|
|
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
|
}
|
|
|
|
private function lockKey(int $artworkId, string $model, string $version): string
|
|
{
|
|
return 'artwork-embedding:lock:'.$artworkId.':'.$model.':'.$version;
|
|
}
|
|
|
|
private function acquireLock(string $key): bool
|
|
{
|
|
try {
|
|
$didSet = Redis::setnx($key, 1);
|
|
if ($didSet) {
|
|
Redis::expire($key, 1800);
|
|
}
|
|
|
|
return (bool) $didSet;
|
|
} catch (\Throwable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function releaseLock(string $key): void
|
|
{
|
|
try {
|
|
Redis::del($key);
|
|
} catch (\Throwable) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|