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 $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 */ 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 $vector * @return array */ 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 } } }