v2 Phase D: home_snapshot table + refresh command, TTL caching, public CDN cache headers for guest pages
This commit is contained in:
parent
72cde51978
commit
11e720f6f3
7 changed files with 221 additions and 6 deletions
71
app/Console/Commands/HomeSnapshot.php
Normal file
71
app/Console/Commands/HomeSnapshot.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BenchmarkRun;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'festbench:snapshot', description: 'Rebuild the flat home_snapshot table (default home view, no UNION at request time)')]
|
||||
class HomeSnapshot extends Command
|
||||
{
|
||||
private const ROWS = 500;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$rows = DB::table('all_results')
|
||||
->join('benchmark_runs', 'benchmark_runs.id', '=', 'all_results.benchmark_run_id')
|
||||
->where('benchmark_runs.visibility', BenchmarkRun::VISIBILITY_PUBLIC)
|
||||
->orderByDesc('all_results.ran_at')
|
||||
->orderByDesc('all_results.id')
|
||||
->limit(self::ROWS)
|
||||
->get([
|
||||
'all_results.id',
|
||||
'all_results.benchmark_run_id',
|
||||
'all_results.ran_at',
|
||||
'all_results.suite',
|
||||
'all_results.task',
|
||||
'all_results.level',
|
||||
'all_results.ok',
|
||||
'all_results.decode',
|
||||
'all_results.user_tps',
|
||||
'all_results.ttft',
|
||||
'all_results.wall',
|
||||
'all_results.total',
|
||||
'benchmark_runs.run_id',
|
||||
'benchmark_runs.model_id',
|
||||
'benchmark_runs.model_quant',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($rows) {
|
||||
DB::table('home_snapshot')->truncate();
|
||||
|
||||
foreach ($rows->values() as $index => $row) {
|
||||
DB::table('home_snapshot')->insert([
|
||||
'benchmark_run_id' => $row->benchmark_run_id,
|
||||
'run_id' => $row->run_id,
|
||||
'ran_at' => $row->ran_at,
|
||||
'suite' => $row->suite,
|
||||
'model_id' => $row->model_id,
|
||||
'model_quant' => $row->model_quant,
|
||||
'task' => $row->task,
|
||||
'level' => $row->level,
|
||||
'ok' => $row->ok,
|
||||
'decode' => $row->decode,
|
||||
'user_tps' => $row->user_tps,
|
||||
'ttft' => $row->ttft,
|
||||
'wall' => $row->wall,
|
||||
'total' => $row->total,
|
||||
'sequence' => $index,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('home_snapshot rebuilt: '.$rows->count().' rows.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
38
app/Http/Middleware/CachePublicGuestPages.php
Normal file
38
app/Http/Middleware/CachePublicGuestPages.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Livewire\Features\SupportDisablingBackButtonCache\SupportDisablingBackButtonCache;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Guest pages carry shared public cache headers so a CDN can absorb the
|
||||
* bulk of page views; authenticated views (which may embed private rows)
|
||||
* must never be cached publicly. Data staleness is bounded by the TTL —
|
||||
* benchmark results a couple of minutes old are correct behavior.
|
||||
*
|
||||
* Livewire's DisableBackButtonCacheMiddleware wraps ours and overwrites
|
||||
* Cache-Control on the way out whenever its static flag is set by a
|
||||
* component render. We clear that flag after the response is built so
|
||||
* our CDN headers survive; browser bfcache loss is acceptable here.
|
||||
*/
|
||||
class CachePublicGuestPages
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
SupportDisablingBackButtonCache::$disableBackButtonCache = false;
|
||||
|
||||
if (auth()->check() || ! $request->isMethodSafe()) {
|
||||
$response->headers->set('Cache-Control', 'private, no-store');
|
||||
} else {
|
||||
$ttl = (int) config('festbench.home_cache_ttl', 300);
|
||||
$response->headers->set('Cache-Control', "public, max-age={$ttl}, s-maxage={$ttl}, stale-while-revalidate=60");
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\CachePublicGuestPages;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -11,7 +12,9 @@
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
$middleware->web(append: [
|
||||
CachePublicGuestPages::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Flat, precomputed snapshot of the default home view (recent results
|
||||
* across suites, newest first). Written by the scheduled
|
||||
* festbench:snapshot command — the request path reads this small table
|
||||
* instead of running the UNION. Only the default view is snapshotted;
|
||||
* filtered/sorted drill-downs use the view or concrete tables.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('home_snapshot', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('benchmark_run_id')->index();
|
||||
$table->string('run_id', 32);
|
||||
$table->timestamp('ran_at')->nullable();
|
||||
$table->string('suite', 32);
|
||||
$table->string('model_id')->nullable();
|
||||
$table->string('model_quant', 64)->nullable();
|
||||
$table->string('task', 128);
|
||||
$table->string('level', 16)->nullable();
|
||||
$table->boolean('ok')->nullable();
|
||||
$table->double('decode')->nullable();
|
||||
$table->double('user_tps')->nullable();
|
||||
$table->double('ttft')->nullable();
|
||||
$table->double('wall')->nullable();
|
||||
$table->unsignedInteger('total')->nullable();
|
||||
$table->unsignedInteger('sequence')->index()->comment('snapshot row order');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('home_snapshot');
|
||||
}
|
||||
};
|
||||
|
|
@ -50,6 +50,12 @@ public function results(): LengthAwarePaginator
|
|||
$cacheKey = 'home:'.($user?->id ?? 'guest').':'.md5(json_encode([$this->filters, $this->sortBy, $this->sortDirection, $this->getPage()]));
|
||||
|
||||
return Cache::remember($cacheKey, $ttl, function () use ($user) {
|
||||
// Default guest view (no filters, no custom sort): the flat
|
||||
// precomputed snapshot — no UNION runs at request time.
|
||||
if ($user === null && $this->isDefaultView()) {
|
||||
return DB::table('home_snapshot')->orderBy('sequence')->paginate(25);
|
||||
}
|
||||
|
||||
$query = $this->baseQuery($user)
|
||||
->tap(fn ($q) => $this->applySorting($q))
|
||||
->tap(fn ($q) => $this->applyFilters($q));
|
||||
|
|
@ -58,6 +64,14 @@ public function results(): LengthAwarePaginator
|
|||
});
|
||||
}
|
||||
|
||||
private function isDefaultView(): bool
|
||||
{
|
||||
return ($this->sortBy === null || $this->sortBy === 'ran_at')
|
||||
&& $this->sortDirection === 'desc'
|
||||
&& collect($this->filters)->filter(fn ($v) => $v !== null && $v !== '')->isEmpty()
|
||||
&& $this->getPage() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The combined query. Suite filter unset → the all_results view (one
|
||||
* UNION plan for everything); suite filter set → the concrete suite
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote')->hourly();
|
||||
// Snapshot the flat default home view every 5 minutes — at high traffic the
|
||||
// request path never runs the UNION. Cheap on this data volume.
|
||||
Schedule::command('festbench:snapshot')->everyFiveMinutes();
|
||||
|
||||
// Ensure next month's Postgres partitions exist before inserts need them.
|
||||
// (Created by the migration for +/-1 year; this keeps future months coming.)
|
||||
// Placeholder command added when partition maintenance is wired up.
|
||||
|
|
|
|||
43
tests/Feature/HomeSnapshotTest.php
Normal file
43
tests/Feature/HomeSnapshotTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Models\BenchmarkRun;
|
||||
use App\Models\SoftbenchResult;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
test('festbench:snapshot rebuilds the flat home view', function () {
|
||||
$run = BenchmarkRun::factory()->create(['suite' => 'softbench', 'model_id' => 'snap/model', 'visibility' => 'public']);
|
||||
SoftbenchResult::factory()->create(['benchmark_run_id' => $run->id, 'task' => 'snap-task']);
|
||||
BenchmarkRun::factory()->private()->create(['suite' => 'softbench']);
|
||||
|
||||
Artisan::call('festbench:snapshot');
|
||||
|
||||
$rows = DB::table('home_snapshot')->get();
|
||||
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and($rows[0]->task)->toBe('snap-task')
|
||||
->and($rows[0]->model_id)->toBe('snap/model')
|
||||
->and($rows[0]->run_id)->toBe($run->run_id);
|
||||
});
|
||||
|
||||
test('guest default home view reads the snapshot, no union needed', function () {
|
||||
$run = BenchmarkRun::factory()->create(['suite' => 'softbench', 'visibility' => 'public']);
|
||||
SoftbenchResult::factory()->create(['benchmark_run_id' => $run->id, 'task' => 'snapshotted-task']);
|
||||
Artisan::call('festbench:snapshot');
|
||||
|
||||
// The snapshot is the only path that knows this task name; seeing it
|
||||
// proves the default view served from home_snapshot.
|
||||
$this->get(route('home'))->assertOk()->assertSee('snapshotted-task');
|
||||
});
|
||||
|
||||
test('guest pages get public cache headers, authenticated pages do not', function () {
|
||||
$guest = $this->get(route('home'));
|
||||
expect($guest->headers->get('Cache-Control'))->toContain('public')
|
||||
->and($guest->headers->get('Cache-Control'))->toContain('s-maxage');
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
$auth = $this->get(route('home'));
|
||||
|
||||
expect($auth->headers->get('Cache-Control'))->toContain('private')
|
||||
->and($auth->headers->get('Cache-Control'))->not->toContain('public');
|
||||
});
|
||||
Loading…
Reference in a new issue