Tighten artwork hourly snapshot window queries.

Reduce snapshot work to the active hour window so metrics aggregation stays cheaper on the hot path.
This commit is contained in:
2026-09-20 14:50:59 +02:00
parent 0b6f3a95e3
commit 12da3c5081
@@ -102,42 +102,48 @@ final class ArtworkHourlySnapshotWindow
*/ */
public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder
{ {
$inWindow = DB::table('artwork_metric_snapshots_hourly as snapshots'); $start = \Carbon\Carbon::parse($start);
$this->constrainOwner($inWindow, $userId); $baselineFrom = (clone $start)->subHours(self::BASELINE_LOOKBACK_HOURS);
$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');
$baselineFrom = \Carbon\Carbon::parse($start)->subHours(self::BASELINE_LOOKBACK_HOURS); // Resolve the three global hourly buckets first. These are cheap indexed
$baseline = DB::table('artwork_metric_snapshots_hourly as snapshots'); // lookups and avoid aggregating millions of cumulative snapshot rows.
$this->constrainOwner($baseline, $userId); $latestBucket = DB::table('artwork_metric_snapshots_hourly')
$baseline ->where('bucket_hour', '>=', $start)
->where('snapshots.bucket_hour', '<', $start) ->max('bucket_hour');
->where('snapshots.bucket_hour', '>=', $baselineFrom)
->groupBy('snapshots.artwork_id') $firstBucket = DB::table('artwork_metric_snapshots_hourly')
->select('snapshots.artwork_id') ->where('bucket_hour', '>=', $start)
->selectRaw('MAX(snapshots.views_count) as views_baseline') ->min('bucket_hour');
->selectRaw('MAX(snapshots.downloads_count) as downloads_baseline')
->selectRaw('MAX(snapshots.favourites_count) as favourites_baseline') $baselineBucket = DB::table('artwork_metric_snapshots_hourly')
->selectRaw('MAX(snapshots.comments_count) as comments_baseline') ->where('bucket_hour', '<', $start)
->selectRaw('MAX(snapshots.shares_count) as shares_baseline'); ->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); $bornSql = $this->quotedTimestamp($start);
return DB::query() return DB::query()
->fromSub($inWindow, 'win') ->fromSub($latest, 'win')
->leftJoinSub($baseline, 'base', 'base.artwork_id', '=', 'win.artwork_id') ->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') ->join('artworks', 'artworks.id', '=', 'win.artwork_id')
->select('win.artwork_id') ->select('win.artwork_id')
->selectRaw($this->deltaExpression('views', $bornSql) . ' as views_delta') ->selectRaw($this->deltaExpression('views', $bornSql) . ' as views_delta')
@@ -147,6 +153,7 @@ final class ArtworkHourlySnapshotWindow
->selectRaw($this->deltaExpression('shares', $bornSql) . ' as shares_delta'); ->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} * @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'); ->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 private function quotedTimestamp(CarbonInterface|\DateTimeInterface $start): string
{ {
return DB::getPdo()->quote(\Carbon\Carbon::parse($start)->toDateTimeString()); return DB::getPdo()->quote(\Carbon\Carbon::parse($start)->toDateTimeString());
@@ -202,7 +237,7 @@ final class ArtworkHourlySnapshotWindow
private function deltaExpression(string $metric, string $bornSql): string private function deltaExpression(string $metric, string $bornSql): string
{ {
$latest = "COALESCE(win.{$metric}_latest, 0)"; $latest = "COALESCE(win.{$metric}_latest, 0)";
$min = "COALESCE(win.{$metric}_min, 0)"; $min = "COALESCE(first.{$metric}_min, 0)";
$base = "base.{$metric}_baseline"; $base = "base.{$metric}_baseline";
$bornInWindow = "COALESCE(artworks.published_at, artworks.created_at) >= {$bornSql}"; $bornInWindow = "COALESCE(artworks.published_at, artworks.created_at) >= {$bornSql}";
$observed = $this->clampNonNegative("{$latest} - {$min}"); $observed = $this->clampNonNegative("{$latest} - {$min}");