redisAvailable()) { $this->pushDelta($artworkId, 'views', $by); return; $this->applyDelta($artworkId, ['views' => $by]); } /** * Increment downloads for an artwork. */ public function incrementDownloads(int $artworkId, int $by = 1, bool $defer = false): void { if ($defer && $this->redisAvailable()) { $this->pushDelta($artworkId, 'downloads', $by); return; /** * Increment views using an Artwork model. Preferred API-first signature. */ public function incrementViewsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void { $this->incrementViews((int) $artwork->id, $by, $defer); } } $this->applyDelta($artworkId, ['downloads' => $by]); } /** * Apply a set of deltas to the artwork_stats row inside a transaction. * This method is safe to call from jobs or synchronously. * * @param int $artworkId * @param array $deltas */ public function applyDelta(int $artworkId, array $deltas): void { try { DB::transaction(function () use ($artworkId, $deltas) { // Ensure a stats row exists. Insert default zeros if missing. DB::table('artwork_stats')->insertOrIgnore([ 'artwork_id' => $artworkId, /** * Increment downloads using an Artwork model. Preferred API-first signature. */ public function incrementDownloadsForArtwork(Artwork $artwork, int $by = 1, bool $defer = true): void { $this->incrementDownloads((int) $artwork->id, $by, $defer); } 'views' => 0, 'downloads' => 0, 'favorites' => 0, 'rating_avg' => 0, 'rating_count' => 0, ]); foreach ($deltas as $column => $value) { // Only allow known columns to avoid SQL injection if (! in_array($column, ['views', 'downloads', 'favorites', 'rating_count'], true)) { continue; } DB::table('artwork_stats') ->where('artwork_id', $artworkId) ->increment($column, (int) $value); } }); } catch (Throwable $e) { Log::error('Failed to apply artwork stats delta', ['artwork_id' => $artworkId, 'deltas' => $deltas, 'error' => $e->getMessage()]); } } /** * Push a delta to Redis queue for async processing. */ protected function pushDelta(int $artworkId, string $field, int $value): void { $payload = json_encode([ 'artwork_id' => $artworkId, 'field' => $field, 'value' => $value, 'ts' => time(), ]); try { Redis::rpush($this->redisKey, $payload); } catch (Throwable $e) { // If Redis is unavailable, fallback to immediate apply to avoid data loss Log::warning('Redis unavailable for artwork stats; applying immediately', ['error' => $e->getMessage()]); $this->applyDelta($artworkId, [$field => $value]); } } /** * Drain and apply queued deltas from Redis. Returns number processed. * Designed to be invoked by a queued job or artisan command. */ public function processPendingFromRedis(int $max = 1000): int { if (! $this->redisAvailable()) { return 0; } $processed = 0; try { while ($processed < $max) { $item = Redis::lpop($this->redisKey); if (! $item) { break; } $decoded = json_decode($item, true); if (! is_array($decoded) || empty($decoded['artwork_id']) || empty($decoded['field'])) { continue; $this->applyDelta((int) $decoded['artwork_id'], [$decoded['field'] => (int) ($decoded['value'] ?? 1)]); $processed++; } } catch (Throwable $e) { Log::error('Error while processing artwork stats from Redis', ['error' => $e->getMessage()]); } return $processed; } protected function redisAvailable(): bool { try { // Redis facade may throw if not configured $pong = Redis::connection()->ping(); return (bool) $pong; } catch (Throwable $e) { return false; } } }