Files
SkinbaseNova/app/Services/Metrics/ArtworkHourlySnapshotWindow.php
T
klevze 12da3c5081 Tighten artwork hourly snapshot window queries.
Reduce snapshot work to the active hour window so metrics aggregation stays cheaper on the hot path.
2026-09-20 14:50:59 +02:00

296 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Metrics;
use Carbon\CarbonInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Windowed deltas from cumulative hourly artwork metric snapshots.
*
* Each snapshot stores running totals. Period growth is:
* latest in window
* - baseline at or immediately before the window start
*
* If the artwork was created/published during the window, baseline is 0.
* If an older artwork has no pre-window snapshot (warm-up / missing hours),
* fall back to MIN in the window — never SUM cumulatives, never lifetime totals.
*/
final class ArtworkHourlySnapshotWindow
{
/**
* How far before the window start to look for a baseline snapshot.
* Keeps the baseline scan bounded at 30-day table scale.
*/
public const BASELINE_LOOKBACK_HOURS = 36;
/**
* @return array{
* views: int,
* downloads: int,
* favourites: int,
* comments: int,
* shares: int,
* requested_days: int,
* table_coverage_days: float,
* user_coverage_days: float,
* window_complete: bool,
* oldest_bucket: ?string,
* newest_bucket: ?string,
* user_oldest_bucket: ?string,
* user_newest_bucket: ?string,
* }
*/
public function userWindow(int $userId, int $days = 30): array
{
$days = max(1, $days);
$coverage = $this->tableCoverage($days);
$empty = $this->emptyResult($days, $coverage);
if ($userId <= 0 || ! Schema::hasTable('artwork_metric_snapshots_hourly')) {
return $empty;
}
$since = now()->subDays($days);
$row = DB::query()
->fromSub($this->artworkPeriodDeltas($since, $userId), 'deltas')
->selectRaw('COALESCE(SUM(views_delta), 0) as views')
->selectRaw('COALESCE(SUM(downloads_delta), 0) as downloads')
->selectRaw('COALESCE(SUM(favourites_delta), 0) as favourites')
->selectRaw('COALESCE(SUM(comments_delta), 0) as comments')
->selectRaw('COALESCE(SUM(shares_delta), 0) as shares')
->first();
$userBounds = DB::table('artwork_metric_snapshots_hourly as snapshots')
->join('artworks', 'artworks.id', '=', 'snapshots.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->where('snapshots.bucket_hour', '>=', $since)
->selectRaw('MIN(snapshots.bucket_hour) as oldest_bucket')
->selectRaw('MAX(snapshots.bucket_hour) as newest_bucket')
->first();
$userCoverageDays = $this->coverageDays(
$userBounds->oldest_bucket ?? null,
$userBounds->newest_bucket ?? null,
);
return [
'views' => (int) ($row->views ?? 0),
'downloads' => (int) ($row->downloads ?? 0),
'favourites' => (int) ($row->favourites ?? 0),
'comments' => (int) ($row->comments ?? 0),
'shares' => (int) ($row->shares ?? 0),
'requested_days' => $days,
'table_coverage_days' => $coverage['coverage_days'],
'user_coverage_days' => $userCoverageDays,
'window_complete' => $coverage['window_complete'],
'oldest_bucket' => $coverage['oldest_bucket'],
'newest_bucket' => $coverage['newest_bucket'],
'user_oldest_bucket' => $userBounds->oldest_bucket ?? null,
'user_newest_bucket' => $userBounds->newest_bucket ?? null,
];
}
/**
* Per-artwork non-negative period deltas for cumulative snapshot columns.
*/
public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder
{
$start = \Carbon\Carbon::parse($start);
$baselineFrom = (clone $start)->subHours(self::BASELINE_LOOKBACK_HOURS);
// 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($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')
->selectRaw($this->deltaExpression('downloads', $bornSql) . ' as downloads_delta')
->selectRaw($this->deltaExpression('favourites', $bornSql) . ' as favourites_delta')
->selectRaw($this->deltaExpression('comments', $bornSql) . ' as comments_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}
*/
public function tableCoverage(int $days = 30): array
{
$days = max(1, $days);
if (! Schema::hasTable('artwork_metric_snapshots_hourly')) {
return [
'oldest_bucket' => null,
'newest_bucket' => null,
'coverage_days' => 0.0,
'window_complete' => false,
'requested_days' => $days,
];
}
$bounds = DB::table('artwork_metric_snapshots_hourly')
->selectRaw('MIN(bucket_hour) as oldest_bucket')
->selectRaw('MAX(bucket_hour) as newest_bucket')
->first();
$coverageDays = $this->coverageDays(
$bounds->oldest_bucket ?? null,
$bounds->newest_bucket ?? null,
);
return [
'oldest_bucket' => $bounds->oldest_bucket ?? null,
'newest_bucket' => $bounds->newest_bucket ?? null,
'coverage_days' => $coverageDays,
'window_complete' => $coverageDays + (1 / 24) >= $days,
'requested_days' => $days,
];
}
private function constrainOwner(Builder $query, ?int $userId): void
{
if ($userId === null) {
return;
}
$query->join('artworks as owner_artworks', 'owner_artworks.id', '=', 'snapshots.artwork_id')
->where('owner_artworks.user_id', $userId)
->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());
}
private function deltaExpression(string $metric, string $bornSql): string
{
$latest = "COALESCE(win.{$metric}_latest, 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}");
$fromBaseline = $this->clampNonNegative("{$latest} - {$base}");
return "CASE WHEN {$bornInWindow} THEN {$latest} WHEN {$base} IS NOT NULL THEN {$fromBaseline} ELSE {$observed} END";
}
private function clampNonNegative(string $expression): string
{
if (DB::connection()->getDriverName() === 'sqlite') {
return "CASE WHEN ({$expression}) > 0 THEN ({$expression}) ELSE 0 END";
}
return "GREATEST({$expression}, 0)";
}
/**
* @return array<string, mixed>
*/
private function emptyResult(int $days, array $coverage): array
{
return [
'views' => 0,
'downloads' => 0,
'favourites' => 0,
'comments' => 0,
'shares' => 0,
'requested_days' => $days,
'table_coverage_days' => $coverage['coverage_days'],
'user_coverage_days' => 0.0,
'window_complete' => $coverage['window_complete'],
'oldest_bucket' => $coverage['oldest_bucket'],
'newest_bucket' => $coverage['newest_bucket'],
'user_oldest_bucket' => null,
'user_newest_bucket' => null,
];
}
private function coverageDays(?string $oldest, ?string $newest): float
{
if ($oldest === null || $newest === null) {
return 0.0;
}
$rangeStart = \Carbon\Carbon::parse($oldest);
$rangeEnd = \Carbon\Carbon::parse($newest);
if ($rangeEnd->lessThan($rangeStart)) {
return 0.0;
}
return round(abs($rangeStart->diffInMinutes($rangeEnd)) / 1440, 2);
}
}