Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.
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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Services\Metrics\ArtworkHourlySnapshotWindow;
|
||||
use App\Services\Studio\StudioMetricsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function snapshotArtworkForUser(int $userId): \App\Models\Artwork
|
||||
{
|
||||
return \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $userId,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(40),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
}
|
||||
|
||||
function insertHourlySnapshot(int $artworkId, $bucketHour, int $views, int $favourites = 0, int $shares = 0, int $comments = 0, int $downloads = 0): void
|
||||
{
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => $views,
|
||||
'downloads_count' => $downloads,
|
||||
'favourites_count' => $favourites,
|
||||
'comments_count' => $comments,
|
||||
'shares_count' => $shares,
|
||||
]);
|
||||
}
|
||||
|
||||
it('computes 30d views as max-min per artwork not sum of cumulatives', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 100, 10, 1, 2, 4);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(1)->startOfHour(), 130, 16, 4, 5, 9);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(30)
|
||||
->and($window['favourites'])->toBe(6)
|
||||
->and($window['shares'])->toBe(3)
|
||||
->and($window['comments'])->toBe(3)
|
||||
->and($window['downloads'])->toBe(5)
|
||||
->and($window['views'])->not->toBe(230);
|
||||
});
|
||||
|
||||
it('sums window deltas across a creator\'s artworks', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$a = snapshotArtworkForUser($user->id);
|
||||
$b = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($a->id, now()->subDays(5)->startOfHour(), 50);
|
||||
insertHourlySnapshot($a->id, now()->subHour()->startOfHour(), 80);
|
||||
insertHourlySnapshot($b->id, now()->subDays(5)->startOfHour(), 10);
|
||||
insertHourlySnapshot($b->id, now()->subHour()->startOfHour(), 15);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(35);
|
||||
});
|
||||
|
||||
it('does not treat zero growth as lifetime totals', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
DB::table('artwork_stats')->updateOrInsert(
|
||||
['artwork_id' => $artwork->id],
|
||||
['views' => 9999, 'favorites' => 50, 'shares_count' => 7]
|
||||
);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 40);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 40);
|
||||
|
||||
Cache::flush();
|
||||
$kpis = app(StudioMetricsService::class)->getDashboardKpis($user->id);
|
||||
|
||||
expect($kpis['views_30d'])->toBe(0)
|
||||
->and($kpis['favourites_30d'])->toBe(0)
|
||||
->and($kpis['snapshot_window']['window_complete'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('marks a short table history as an incomplete 30-day window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 10);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 18);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(8)
|
||||
->and($window['window_complete'])->toBeFalse()
|
||||
->and($window['table_coverage_days'])->toBeLessThan(30)
|
||||
->and($window['requested_days'])->toBe(30);
|
||||
});
|
||||
|
||||
it('counts all views for artwork created during the window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(3),
|
||||
'created_at' => now()->subDays(3),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(3)->startOfHour(), 40);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 90);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(90);
|
||||
});
|
||||
|
||||
it('uses a pre-window snapshot as baseline for older artwork', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(30)->subHour()->startOfHour(), 200);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 220);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 250);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(50);
|
||||
});
|
||||
|
||||
it('does not treat a single in-window snapshot of older artwork as lifetime views', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 400);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(0);
|
||||
});
|
||||
|
||||
it('treats a single snapshot of a new artwork as growth from zero', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subHours(2),
|
||||
'created_at' => now()->subHours(2),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 12);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(12);
|
||||
});
|
||||
|
||||
it('ignores creator-journey snapshots older than the retention window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(40)->startOfHour(), 10, 0, 0, 0, 1);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(39)->startOfHour(), 10, 0, 0, 0, 500);
|
||||
|
||||
$lookbackDays = (int) config('metrics.hourly_snapshot_retention_days', 30);
|
||||
$count = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('bucket_hour', '>=', now()->subDays($lookbackDays))
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
|
||||
function leaderboardArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(fn () => Artwork::factory()->create(array_merge([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(40),
|
||||
'deleted_at' => null,
|
||||
], $attrs)));
|
||||
}
|
||||
|
||||
function leaderboardSnapshot(int $artworkId, $hour, int $views): void
|
||||
{
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $hour,
|
||||
'views_count' => $views,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
it('scores monthly artwork leaderboards from period view deltas not cumulative sums', function () {
|
||||
$old = leaderboardArtwork();
|
||||
$bornInMonth = leaderboardArtwork([
|
||||
'published_at' => now()->subDays(4),
|
||||
'created_at' => now()->subDays(4),
|
||||
]);
|
||||
|
||||
leaderboardSnapshot($old->id, now()->subDays(20)->startOfHour(), 100);
|
||||
leaderboardSnapshot($old->id, now()->subHour()->startOfHour(), 140);
|
||||
leaderboardSnapshot($bornInMonth->id, now()->subDays(3)->startOfHour(), 25);
|
||||
leaderboardSnapshot($bornInMonth->id, now()->subHour()->startOfHour(), 55);
|
||||
|
||||
app(LeaderboardService::class)->calculateArtworkLeaderboard(Leaderboard::PERIOD_MONTHLY);
|
||||
|
||||
$rows = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_MONTHLY)
|
||||
->get()
|
||||
->keyBy('entity_id');
|
||||
|
||||
expect((int) $rows[$old->id]->score)->toBe(40)
|
||||
->and((int) $rows[$bornInMonth->id]->score)->toBe(55)
|
||||
->and((int) $rows[$old->id]->score)->not->toBe(240);
|
||||
});
|
||||
|
||||
it('scores daily and weekly artwork leaderboards from the same delta rules', function () {
|
||||
$artwork = leaderboardArtwork();
|
||||
|
||||
leaderboardSnapshot($artwork->id, now()->subDays(7)->subHour()->startOfHour(), 10);
|
||||
leaderboardSnapshot($artwork->id, now()->subDays(3)->startOfHour(), 18);
|
||||
leaderboardSnapshot($artwork->id, now()->subDay()->subHour()->startOfHour(), 18);
|
||||
leaderboardSnapshot($artwork->id, now()->subHours(2)->startOfHour(), 21);
|
||||
|
||||
$service = app(LeaderboardService::class);
|
||||
$service->calculateArtworkLeaderboard(Leaderboard::PERIOD_WEEKLY);
|
||||
$service->calculateArtworkLeaderboard(Leaderboard::PERIOD_DAILY);
|
||||
|
||||
$weekly = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_WEEKLY)
|
||||
->where('entity_id', $artwork->id)
|
||||
->value('score');
|
||||
|
||||
$daily = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_DAILY)
|
||||
->where('entity_id', $artwork->id)
|
||||
->value('score');
|
||||
|
||||
expect((int) $weekly)->toBe(11)
|
||||
->and((int) $daily)->toBe(3);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('writes heat_score from chunked hourly snapshot loads', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(2),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
$currentHour = now()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $prevHour,
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 1,
|
||||
'favourites_count' => 1,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $currentHour,
|
||||
'views_count' => 40,
|
||||
'downloads_count' => 4,
|
||||
'favourites_count' => 3,
|
||||
'comments_count' => 1,
|
||||
'shares_count' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:recalculate-heat', ['--chunk' => 1])->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((float) $row->heat_score)->toBeGreaterThan(0);
|
||||
});
|
||||
Reference in New Issue
Block a user