afterArtworkId = $afterArtworkId; $queue = (string) config('recommendations.queue', 'default'); if ($queue !== '') { $this->onQueue($queue); } } public function __unserialize(array $values): void { $this->unserializeQueuedModels($values); $this->restoreAfterArtworkIdCursor(); } public function cursorAfterArtworkId(): ?int { return $this->afterArtworkId(); } public function handle(): void { $startedAt = microtime(true); $modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1'); $resultLimit = (int) config('recommendations.similarity.result_limit', 30); $maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2); if ($this->artworkId !== null) { $artwork = Artwork::query()->public()->published()->select('id', 'user_id')->find($this->artworkId); if ($artwork instanceof Artwork) { $this->processArtworkSafely($artwork, $modelVersion, $resultLimit, $maxPerAuthor); $this->logBatchComplete($startedAt, 1, false); return; } $this->logBatchComplete($startedAt, 0, false); return; } $artworks = Artwork::query() ->public() ->published() ->select('id', 'user_id') ->when($this->afterArtworkId() !== null, fn ($query) => $query->where('id', '>', $this->afterArtworkId())) ->orderBy('id') ->limit($this->batchSize) ->get(); if ($artworks->isEmpty()) { $this->logBatchComplete($startedAt, 0, false); return; } foreach ($artworks as $artwork) { $this->processArtworkSafely($artwork, $modelVersion, $resultLimit, $maxPerAuthor); } $hasMore = $artworks->count() === $this->batchSize; if ($hasMore) { static::dispatch(null, $this->batchSize, (int) $artworks->last()->id); } $this->logBatchComplete($startedAt, $artworks->count(), $hasMore); } public function failed(\Throwable $exception): void { Log::error('[RecComputeSimilarByBehavior] Job failed permanently.', [ 'artwork_id' => $this->artworkId, 'batch_size' => $this->batchSize, 'after_artwork_id' => $this->afterArtworkId(), 'attempts' => $this->attempts(), 'exception_class' => $exception::class, 'exception_message' => $exception->getMessage(), ]); } private function processArtworkSafely( Artwork $artwork, string $modelVersion, int $resultLimit, int $maxPerAuthor, ): void { try { $this->processArtwork($artwork, $modelVersion, $resultLimit, $maxPerAuthor); } catch (\Throwable $exception) { Log::warning("[RecComputeSimilarByBehavior] Failed for artwork {$artwork->id}: {$exception->getMessage()}", [ 'artwork_id' => $artwork->id, 'exception_class' => $exception::class, ]); } } /** * @param positive-int|0 $processed */ private function logBatchComplete(float $startedAt, int $processed, bool $hasMore): void { Log::info('[RecComputeSimilarByBehavior] Batch complete.', [ 'artwork_id' => $this->artworkId, 'after_artwork_id' => $this->afterArtworkId(), 'processed' => $processed, 'has_more' => $hasMore, 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), 'memory_mb' => round(memory_get_peak_usage(true) / 1048576, 1), ]); } private function processArtwork( Artwork $artwork, string $modelVersion, int $resultLimit, int $maxPerAuthor, ): void { // Fetch top co-occurring artworks (bi-directional) $candidates = DB::table('rec_item_pairs') ->where('a_artwork_id', $artwork->id) ->select(DB::raw('b_artwork_id AS related_id'), 'weight') ->union( DB::table('rec_item_pairs') ->where('b_artwork_id', $artwork->id) ->select(DB::raw('a_artwork_id AS related_id'), 'weight') ) ->orderByDesc('weight') ->limit($resultLimit * 3) ->get(); if ($candidates->isEmpty()) { return; } $relatedIds = $candidates->pluck('related_id')->map(fn ($id) => (int) $id)->all(); // Fetch author info for diversity filtering $authorMap = DB::table('artworks') ->whereIn('id', $relatedIds) ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->where('published_at', '<=', now()) ->whereNull('deleted_at') ->pluck('user_id', 'id') ->all(); // Apply diversity cap $authorCounts = []; $final = []; foreach ($candidates as $cand) { $relatedId = (int) $cand->related_id; if (! isset($authorMap[$relatedId])) { continue; // not public/published } $authorId = (int) $authorMap[$relatedId]; $authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1; if ($authorCounts[$authorId] > $maxPerAuthor) { continue; } $final[] = $relatedId; if (count($final) >= $resultLimit) { break; } } if ($final === []) { return; } RecArtworkRec::query()->updateOrCreate( [ 'artwork_id' => $artwork->id, 'rec_type' => 'similar_behavior', 'model_version' => $modelVersion, ], [ 'recs' => $final, 'computed_at' => now(), ], ); } }