storing analytics data
This commit is contained in:
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
44
app/Console/Commands/FlushRedisStatsCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Drain the Redis artwork-stat delta queue into MySQL.
|
||||
*
|
||||
* The ArtworkStatsService::incrementViews/Downloads methods push compressed
|
||||
* delta payloads to a Redis list (`artwork_stats:deltas`) when Redis is
|
||||
* available. This command drains that queue by applying each delta to the
|
||||
* artwork_stats table via applyDelta().
|
||||
*
|
||||
* Designed to run every 5 minutes so counters stay reasonably fresh while
|
||||
* keeping MySQL write pressure low. If Redis is unavailable the command exits
|
||||
* immediately without error — the service already fell back to direct DB
|
||||
* writes in that case.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:flush-redis-stats
|
||||
* php artisan skinbase:flush-redis-stats --max=500
|
||||
*/
|
||||
class FlushRedisStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:flush-redis-stats {--max=1000 : Maximum deltas to process per run}';
|
||||
protected $description = 'Drain Redis artwork stat delta queue into MySQL';
|
||||
|
||||
public function handle(ArtworkStatsService $service): int
|
||||
{
|
||||
$max = (int) $this->option('max');
|
||||
|
||||
$processed = $service->processPendingFromRedis($max);
|
||||
|
||||
if ($this->getOutput()->isVerbose()) {
|
||||
$this->info("Processed {$processed} artwork-stat delta(s) from Redis.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
42
app/Console/Commands/PruneViewEventsCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Delete artwork_view_events rows older than N days.
|
||||
*
|
||||
* The view event log grows ~proportionally to site traffic. Rows beyond the
|
||||
* retention window are no longer useful for trending (which looks back ≤7
|
||||
* days) or for computing "recently viewed" lists in the UI.
|
||||
*
|
||||
* Default retention is 90 days — long enough for analytics queries and user
|
||||
* history pages, short enough to keep the table from growing unbounded.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:prune-view-events
|
||||
* php artisan skinbase:prune-view-events --days=30
|
||||
*/
|
||||
class PruneViewEventsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:prune-view-events {--days=90 : Delete events older than this many days}';
|
||||
protected $description = 'Delete artwork_view_events rows older than N days';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$deleted = DB::table('artwork_view_events')
|
||||
->where('viewed_at', '<', $cutoff)
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$deleted} view event(s) older than {$days} days (cutoff: {$cutoff}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
57
app/Console/Commands/RecalculateTrendingCommand.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TrendingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:recalculate-trending [--period=24h|7d] [--chunk=1000] [--skip-index]
|
||||
*/
|
||||
class RecalculateTrendingCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:recalculate-trending
|
||||
{--period=7d : Period to recalculate (24h or 7d). Use "all" to run both.}
|
||||
{--chunk=1000 : DB chunk size}
|
||||
{--skip-index : Skip dispatching Meilisearch re-index jobs}';
|
||||
|
||||
protected $description = 'Recalculate trending scores for artworks and sync to Meilisearch';
|
||||
|
||||
public function __construct(private readonly TrendingService $trending)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$period = (string) $this->option('period');
|
||||
$chunkSize = (int) $this->option('chunk');
|
||||
$skipIndex = (bool) $this->option('skip-index');
|
||||
|
||||
$periods = $period === 'all' ? ['24h', '7d'] : [$period];
|
||||
|
||||
foreach ($periods as $p) {
|
||||
if (! in_array($p, ['24h', '7d'], true)) {
|
||||
$this->error("Invalid period '{$p}'. Use 24h, 7d, or all.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Recalculating trending ({$p}) …");
|
||||
$start = microtime(true);
|
||||
$updated = $this->trending->recalculate($p, $chunkSize);
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
|
||||
$this->info(" ✓ {$updated} artworks updated in {$elapsed}s");
|
||||
|
||||
if (! $skipIndex) {
|
||||
$this->info(" Dispatching Meilisearch index jobs …");
|
||||
$this->trending->syncToSearchIndex($p);
|
||||
$this->info(" ✓ Index jobs dispatched");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
97
app/Console/Commands/ResetWindowedStatsCommand.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* php artisan skinbase:reset-windowed-stats --period=24h|7d
|
||||
*
|
||||
* Resets / recomputes the sliding-window stats columns in artwork_stats:
|
||||
*
|
||||
* views_24h / views_7d
|
||||
* — Zeroed on each reset because we have no per-view event log.
|
||||
* Artworks re-accumulate from the next view event onward.
|
||||
* (Low-traffic reset window: 03:30 means minimal trending disruption.)
|
||||
*
|
||||
* downloads_24h / downloads_7d
|
||||
* — Recomputed accurately from the artwork_downloads event log.
|
||||
* A single bulk UPDATE with a correlated COUNT() is safe here because
|
||||
* it runs once nightly/weekly, not in the hot path.
|
||||
*
|
||||
* Scheduled in routes/console.php:
|
||||
* --period=24h daily at 03:30
|
||||
* --period=7d weekly (Monday) at 03:30
|
||||
*/
|
||||
class ResetWindowedStatsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:reset-windowed-stats
|
||||
{--period=24h : Window to reset: 24h or 7d}';
|
||||
|
||||
protected $description = 'Reset windowed view/download counters in artwork_stats';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$period = (string) $this->option('period');
|
||||
|
||||
if (! in_array($period, ['24h', '7d'], true)) {
|
||||
$this->error("Invalid period '{$period}'. Use 24h or 7d.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
[$viewsCol, $downloadsCol, $cutoff] = match ($period) {
|
||||
'24h' => ['views_24h', 'downloads_24h', now()->subDay()],
|
||||
default => ['views_7d', 'downloads_7d', now()->subDays(7)],
|
||||
};
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
// ── 1. Zero the views window column ──────────────────────────────────
|
||||
// We have no per-view event log, so we reset the accumulator.
|
||||
$viewsReset = DB::table('artwork_stats')->update([$viewsCol => 0]);
|
||||
|
||||
// ── 2. Recompute downloads window from the event log ─────────────────
|
||||
// artwork_downloads has created_at, so each row's window is accurate.
|
||||
// Chunked PHP loop avoids MySQL-only functions (GREATEST, INTERVAL)
|
||||
// so this command works in both MySQL (production) and SQLite (tests).
|
||||
$downloadsRecomputed = 0;
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->orderBy('artwork_id')
|
||||
->chunk(1000, function ($rows) use ($downloadsCol, $cutoff, &$downloadsRecomputed): void {
|
||||
foreach ($rows as $row) {
|
||||
$count = DB::table('artwork_downloads')
|
||||
->where('artwork_id', $row->artwork_id)
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->count();
|
||||
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $row->artwork_id)
|
||||
->update([$downloadsCol => max(0, $count)]);
|
||||
|
||||
$downloadsRecomputed++;
|
||||
}
|
||||
});
|
||||
|
||||
$elapsed = round(microtime(true) - $start, 2);
|
||||
|
||||
$this->info("Period: {$period}");
|
||||
$this->info(" {$viewsCol}: zeroed {$viewsReset} rows");
|
||||
$this->info(" {$downloadsCol}: recomputed {$downloadsRecomputed} rows ({$elapsed}s)");
|
||||
|
||||
Log::info('ResetWindowedStats complete', [
|
||||
'period' => $period,
|
||||
'views_col' => $viewsCol,
|
||||
'views_rows_reset' => $viewsReset,
|
||||
'downloads_col' => $downloadsCol,
|
||||
'downloads_recomputed' => $downloadsRecomputed,
|
||||
'elapsed_s' => $elapsed,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||
use App\Console\Commands\AiTagArtworksCommand;
|
||||
use App\Console\Commands\CompareFeedAbCommand;
|
||||
use App\Console\Commands\RecalculateTrendingCommand;
|
||||
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
@@ -36,6 +37,7 @@ class Kernel extends ConsoleKernel
|
||||
CompareFeedAbCommand::class,
|
||||
AiTagArtworksCommand::class,
|
||||
\App\Console\Commands\MigrateFollows::class,
|
||||
RecalculateTrendingCommand::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +48,9 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user