Files
SGF/scripts/benchmarks/ModuleAssociationCompareTest.php
2026-11-02 20:29:34 +02:00

161 lines
12 KiB
PHP

<?php
namespace Tests\Benchmarks;
use App\Modules\Identity\Http\ModuleRecordsController;
use App\Modules\Identity\Services\BaselineModuleAssociationReview;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
require_once __DIR__.'/ModuleAssociationLoadTest.php';
final class BaselineAssociationController
{
public function index(Request $r) { return response()->json(app(BaselineModuleAssociationReview::class)->listing($r))->header('Cache-Control', 'no-store'); }
}
final class ModuleAssociationCompareTest extends ModuleAssociationLoadTest
{
private ?array $metrics = null;
private array $sql = [];
private int $start = 0;
// Overrides the old test: only this paired experiment is executed.
public function test_synthetic_volume_and_query_plans(): void
{
$this->assertSame('8.4.27', PHP_VERSION);
$run = (string) getenv('SGF_ISOLATED_REGRESSION_RUN');
$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D', $run);
$identity = DB::selectOne("SELECT current_database() AS database,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
$this->assertSame('sgf_tempo_test', $identity->database); $this->assertSame('sgf_test_runner', $identity->role);
$this->assertSame('sgf_modular_regression_'.$run, $identity->cluster); $this->assertSame(55439, (int) $identity->port);
require_once base_path('../baseline-review.php');
$this->withoutExceptionHandling();
DB::listen(function (QueryExecuted $q) {
if ($this->metrics === null) return;
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKLOAD_WROTE_SQL');
$this->metrics['queries']++; $this->metrics['database_ms'] += $q->time;
$key = hash('sha256', $q->sql);
$entry = $this->sql[$key] ?? ['sql' => $q->sql, 'bindings' => $q->bindings, 'count' => 0, 'database_ms' => 0.0];
$entry['count']++; $entry['database_ms'] += $q->time; $this->sql[$key] = $entry;
if ((hrtime(true) - $this->start) / 1e6 > 15000 || $this->metrics['queries'] > 2500) throw new \RuntimeException('CASE_BUDGET_REACHED');
});
$report = ['date' => '2026-11-02', 'identity' => $identity, 'baseline_checkpoint' => '340f3bb', 'baseline_sha256' => getenv('SGF_ASSOCIATION_BASELINE_SHA'), 'optimized_sha256' => getenv('SGF_ASSOCIATION_OPTIMIZED_SHA'),
'method' => 'Same immutable fixture per scale; baseline/batch requests alternate order across three repetitions; indexed batch uses the identical fixture after index creation in a savepoint. Warm kernel/buffers, no concurrent capacity claim.',
'bounds' => ['request_ms' => 15000, 'statement_ms' => 3000, 'queries' => 2500], 'datasets' => []];
$initial = $this->fingerprints();
try {
foreach ([1000, 5000, 10000] as $n) {
DB::beginTransaction();
try {
$f = $this->fixture($n, true); $actor = $f['identity'];
$this->actingAs($actor['manager'])->withSession(['auth_version' => $actor['manager']->auth_version]);
$token = $this->postJson('/api/context', ['tenant_id' => $actor['tenant'], 'company_id' => $actor['company'], 'unit_id' => $actor['unit'], 'module_id' => null])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context', $token);
$dataset = ['records_per_area' => $n, 'events' => DB::table('identity_audit')->where('tenant_id', $actor['tenant'])->where('action', 'like', 'module.record.%')->count(), 'cases' => []];
$cases = [];
foreach (array_keys($f['ids']) as $area) foreach ([25, 100] as $size) $cases[] = [$area, 'all', $size];
foreach (['pending', 'associated', 'removed'] as $state) $cases[] = ['clients', $state, 25];
foreach ($cases as [$area, $state, $size]) {
$expected = array_values(array_filter($f['ids'][$area], fn ($id) => match ($state) { 'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true }));
$case = ['area' => $area, 'state' => $state, 'page_size' => $size, 'variants' => ['baseline' => [], 'batch' => [], 'indexed_batch' => []]];
foreach (['baseline', 'batch'] as $mode) { $this->mode($mode); $this->getJson('/api/module-records?area='.$area.'&per_page=1')->assertOk(); }
for ($i = 0; $i < 3; $i++) foreach ($i % 2 ? ['batch', 'baseline'] : ['baseline', 'batch'] as $mode) {
$prior = $case['variants'][$mode];
if ($prior && end($prior)['status'] !== 'ok') continue;
$this->mode($mode); $case['variants'][$mode][] = $this->sample($area, $state, $size, $expected);
}
$dataset['cases'][] = $case;
fwrite(STDOUT, "\nPAIR $n $area/$state/$size baseline=".$this->brief($case['variants']['baseline']).' batch='.$this->brief($case['variants']['batch'])."\n");
}
DB::beginTransaction();
try {
$dataset['experimental_indexes'] = [
"CREATE INDEX sgf_qa_assoc_links ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),(details->>'module_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated')",
"CREATE INDEX sgf_qa_assoc_revision ON identity_audit (tenant_id,company_id,(details->>'area'),(details->>'record_id'),id DESC) WHERE action IN ('module.record.associated','module.record.disassociated','module.record.reviewed')",
];
DB::statement("SET LOCAL statement_timeout = '30s'");
foreach ($dataset['experimental_indexes'] as $ddl) DB::statement($ddl);
DB::statement('ANALYZE identity_audit');
$this->mode('indexed_batch');
foreach ($dataset['cases'] as &$case) {
$area = $case['area']; $state = $case['state']; $size = $case['page_size'];
$expected = array_values(array_filter($f['ids'][$area], fn ($id) => match ($state) { 'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true }));
$this->getJson('/api/module-records?area='.$area.'&per_page=1')->assertOk();
for ($i = 0; $i < 3; $i++) { $sample = $this->sample($area, $state, $size, $expected); $case['variants']['indexed_batch'][] = $sample; if ($sample['status'] !== 'ok') break; }
$hashes = [];
foreach ($case['variants'] as $samples) foreach ($samples as $sample) if ($sample['status'] === 'ok') $hashes[] = $sample['response_sha256'];
$this->assertCount(1, array_unique($hashes), 'Successful variants must return byte-identical JSON');
$case['successful_responses_identical'] = true;
foreach ($case['variants'] as $mode => $samples) $case['summary'][$mode] = $this->summarize($samples);
fwrite(STDOUT, "\nINDEX $n $area/$state/$size ".$this->brief($case['variants']['indexed_batch'])."\n");
}
unset($case);
} finally { $this->metrics = null; DB::rollBack(); }
$this->assertSame(0, (int) DB::selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='public' AND indexname LIKE 'sgf_qa_assoc_%'")->n);
$dataset['experimental_indexes_rolled_back'] = true;
$report['datasets'][] = $dataset;
} finally { $this->metrics = null; $this->mode('batch'); DB::rollBack(); }
$this->assertSame($initial, $this->fingerprints(), 'Committed records must survive fixture rollback unchanged');
}
$report['committed_data_and_index_catalog_preserved'] = true; $report['completed'] = true;
} finally {
$output = getenv('SGF_ASSOCIATION_LOAD_OUTPUT');
if (!$output || realpath(dirname($output)) !== realpath(base_path('..'))) throw new \RuntimeException('Unexpected report directory');
file_put_contents($output, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
}
}
private function mode(string $mode): void
{
app()->bind(ModuleRecordsController::class, fn () => $mode === 'baseline' ? new BaselineAssociationController() : new ModuleRecordsController());
foreach (app('router')->getRoutes() as $route) if ($route->uri() === 'api/module-records') $route->flushController();
}
private function sample(string $area, string $state, int $size, array $expected): array
{
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
gc_collect_cycles();
$this->sql = []; $this->metrics = ['queries' => 0, 'database_ms' => 0.0];
memory_reset_peak_usage(); $base = memory_get_usage(false); $this->start = hrtime(true); $status = 'ok'; $extra = [];
try {
$r = $this->getJson('/api/module-records?'.http_build_query(['area' => $area, 'state' => $state, 'per_page' => $size]));
$elapsed = (hrtime(true) - $this->start) / 1e6; $stats = $this->metrics; $this->metrics = null;
$peak = memory_get_peak_usage(false); $allocated = memory_get_peak_usage(true);
$r->assertOk()->assertJsonPath('pagination.total', count($expected));
$rows = $r->json('records.'.$area); $this->assertSame(array_slice($expected, 0, $size), array_column($rows, 'id'));
foreach ($rows as $row) { $this->assertSame($row['id'] % 3 === 1 ? ['commercial'] : [], $row['modules']); $this->assertFalse($row['dependencies_restricted']); }
$extra = ['response_sha256' => hash('sha256', $r->getContent()), 'response_bytes' => strlen($r->getContent()), 'rows' => count($rows)];
} catch (\Illuminate\Database\QueryException $e) { $status = 'sql_timeout_or_error'; $extra = ['sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
} catch (\RuntimeException $e) { if ($e->getMessage() !== 'CASE_BUDGET_REACHED') throw $e; $status = 'case_budget_reached';
} finally {
$stats ??= $this->metrics; $elapsed ??= (hrtime(true) - $this->start) / 1e6;
$peak ??= memory_get_peak_usage(false); $allocated ??= memory_get_peak_usage(true);
$this->metrics = null; DB::rollBack();
}
return ['status' => $status, 'elapsed_ms' => round($elapsed, 3), 'queries' => $stats['queries'], 'database_ms' => round($stats['database_ms'], 3),
'peak_php_used_bytes' => $peak, 'incremental_php_used_bytes' => max(0, $peak - $base), 'peak_php_allocated_bytes' => $allocated, ...$extra];
}
private function summarize(array $samples): array
{
$ok = array_values(array_filter($samples, fn ($s) => $s['status'] === 'ok')); $times = array_column($ok, 'elapsed_ms'); sort($times);
return ['completed_samples' => count($ok), 'median_ms' => count($ok) === 3 ? $times[1] : null, 'queries' => $ok[0]['queries'] ?? null,
'peak_php_used_bytes' => $ok ? max(array_column($ok, 'peak_php_used_bytes')) : null,
'incremental_php_used_bytes' => $ok ? max(array_column($ok, 'incremental_php_used_bytes')) : null,
'censored' => count($ok) !== 3, 'last_observed_ms' => end($samples)['elapsed_ms']];
}
private function brief(array $samples): string { $s = $this->summarize($samples); return $s['censored'] ? 'bounded@'.$s['last_observed_ms'] : $s['median_ms'].'ms'; }
private function fingerprints(): array
{
$out = [];
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit', 'users', 'permission_grants', 'company_memberships'] as $table) {
$out[$table] = (array) DB::selectOne("SELECT count(*) AS rows,md5(coalesce(string_agg(row_to_json(t)::text,E'\\n' ORDER BY row_to_json(t)::text),'')) AS hash FROM {$table} t");
}
$out['indexes'] = json_encode(DB::select("SELECT indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY indexname"), JSON_THROW_ON_ERROR);
return $out;
}
}