From 12da3c5081562247134be5391856e8e4e87a3dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20Klev=C5=BEe?= Date: Sun, 20 Sep 2026 14:50:59 +0200 Subject: [PATCH] Tighten artwork hourly snapshot window queries. Reduce snapshot work to the active hour window so metrics aggregation stays cheaper on the hot path. --- .../Metrics/ArtworkHourlySnapshotWindow.php | 97 +++++++++++++------ 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/app/Services/Metrics/ArtworkHourlySnapshotWindow.php b/app/Services/Metrics/ArtworkHourlySnapshotWindow.php index 9eea7b94..2ea13932 100644 --- a/app/Services/Metrics/ArtworkHourlySnapshotWindow.php +++ b/app/Services/Metrics/ArtworkHourlySnapshotWindow.php @@ -102,42 +102,48 @@ final class ArtworkHourlySnapshotWindow */ public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder { - $inWindow = DB::table('artwork_metric_snapshots_hourly as snapshots'); - $this->constrainOwner($inWindow, $userId); - $inWindow - ->where('snapshots.bucket_hour', '>=', $start) - ->groupBy('snapshots.artwork_id') - ->select('snapshots.artwork_id') - ->selectRaw('MAX(snapshots.views_count) as views_latest') - ->selectRaw('MIN(snapshots.views_count) as views_min') - ->selectRaw('MAX(snapshots.downloads_count) as downloads_latest') - ->selectRaw('MIN(snapshots.downloads_count) as downloads_min') - ->selectRaw('MAX(snapshots.favourites_count) as favourites_latest') - ->selectRaw('MIN(snapshots.favourites_count) as favourites_min') - ->selectRaw('MAX(snapshots.comments_count) as comments_latest') - ->selectRaw('MIN(snapshots.comments_count) as comments_min') - ->selectRaw('MAX(snapshots.shares_count) as shares_latest') - ->selectRaw('MIN(snapshots.shares_count) as shares_min'); + $start = \Carbon\Carbon::parse($start); + $baselineFrom = (clone $start)->subHours(self::BASELINE_LOOKBACK_HOURS); - $baselineFrom = \Carbon\Carbon::parse($start)->subHours(self::BASELINE_LOOKBACK_HOURS); - $baseline = DB::table('artwork_metric_snapshots_hourly as snapshots'); - $this->constrainOwner($baseline, $userId); - $baseline - ->where('snapshots.bucket_hour', '<', $start) - ->where('snapshots.bucket_hour', '>=', $baselineFrom) - ->groupBy('snapshots.artwork_id') - ->select('snapshots.artwork_id') - ->selectRaw('MAX(snapshots.views_count) as views_baseline') - ->selectRaw('MAX(snapshots.downloads_count) as downloads_baseline') - ->selectRaw('MAX(snapshots.favourites_count) as favourites_baseline') - ->selectRaw('MAX(snapshots.comments_count) as comments_baseline') - ->selectRaw('MAX(snapshots.shares_count) as shares_baseline'); + // Resolve the three global hourly buckets first. These are cheap indexed + // lookups and avoid aggregating millions of cumulative snapshot rows. + $latestBucket = DB::table('artwork_metric_snapshots_hourly') + ->where('bucket_hour', '>=', $start) + ->max('bucket_hour'); + + $firstBucket = DB::table('artwork_metric_snapshots_hourly') + ->where('bucket_hour', '>=', $start) + ->min('bucket_hour'); + + $baselineBucket = DB::table('artwork_metric_snapshots_hourly') + ->where('bucket_hour', '<', $start) + ->where('bucket_hour', '>=', $baselineFrom) + ->max('bucket_hour'); + + $latest = $this->exactBucketSnapshot( + $latestBucket, + $userId, + 'latest', + ); + + $first = $this->exactBucketSnapshot( + $firstBucket, + $userId, + 'min', + ); + + $baseline = $this->exactBucketSnapshot( + $baselineBucket, + $userId, + 'baseline', + ); $bornSql = $this->quotedTimestamp($start); return DB::query() - ->fromSub($inWindow, 'win') + ->fromSub($latest, 'win') ->leftJoinSub($baseline, 'base', 'base.artwork_id', '=', 'win.artwork_id') + ->leftJoinSub($first, 'first', 'first.artwork_id', '=', 'win.artwork_id') ->join('artworks', 'artworks.id', '=', 'win.artwork_id') ->select('win.artwork_id') ->selectRaw($this->deltaExpression('views', $bornSql) . ' as views_delta') @@ -147,6 +153,7 @@ final class ArtworkHourlySnapshotWindow ->selectRaw($this->deltaExpression('shares', $bornSql) . ' as shares_delta'); } + /** * @return array{oldest_bucket: ?string, newest_bucket: ?string, coverage_days: float, window_complete: bool, requested_days: int} */ @@ -194,6 +201,34 @@ final class ArtworkHourlySnapshotWindow ->whereNull('owner_artworks.deleted_at'); } + private function exactBucketSnapshot( + ?string $bucket, + ?int $userId, + string $suffix, + ): Builder { + $query = DB::table('artwork_metric_snapshots_hourly as snapshots'); + + if (DB::connection()->getDriverName() === 'mysql') { + $query->forceIndex('idx_bucket_artwork'); + } + + $this->constrainOwner($query, $userId); + + if ($bucket === null) { + $query->whereRaw('1 = 0'); + } else { + $query->where('snapshots.bucket_hour', $bucket); + } + + return $query + ->select('snapshots.artwork_id') + ->selectRaw("snapshots.views_count as views_{$suffix}") + ->selectRaw("snapshots.downloads_count as downloads_{$suffix}") + ->selectRaw("snapshots.favourites_count as favourites_{$suffix}") + ->selectRaw("snapshots.comments_count as comments_{$suffix}") + ->selectRaw("snapshots.shares_count as shares_{$suffix}"); + } + private function quotedTimestamp(CarbonInterface|\DateTimeInterface $start): string { return DB::getPdo()->quote(\Carbon\Carbon::parse($start)->toDateTimeString()); @@ -202,7 +237,7 @@ final class ArtworkHourlySnapshotWindow private function deltaExpression(string $metric, string $bornSql): string { $latest = "COALESCE(win.{$metric}_latest, 0)"; - $min = "COALESCE(win.{$metric}_min, 0)"; + $min = "COALESCE(first.{$metric}_min, 0)"; $base = "base.{$metric}_baseline"; $bornInWindow = "COALESCE(artworks.published_at, artworks.created_at) >= {$bornSql}"; $observed = $this->clampNonNegative("{$latest} - {$min}");