Files
SkinbaseNova/app/Jobs/GenerateArtworkEmbeddingJob.php
T
test 62aaa2f704 Harden vision vector search and embedding jobs.
Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
2026-09-20 14:49:19 +02:00

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