Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
261 lines
10 KiB
PHP
261 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
|
|
{
|
|
$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');
|
|
|
|
$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');
|
|
|
|
$bornSql = $this->quotedTimestamp($start);
|
|
|
|
return DB::query()
|
|
->fromSub($inWindow, 'win')
|
|
->leftJoinSub($baseline, 'base', 'base.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 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(win.{$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);
|
|
}
|
|
}
|