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

193 lines
14 KiB
PHP

<?php
namespace Tests\Benchmarks;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
// Explicit invocation only: outside the default test directories. Never migrate.
class ModuleAssociationLoadTest extends TestCase
{
use DatabaseTransactions;
private ?array $measurement = null;
private array $queries = [];
private int $started = 0;
private array $report = [];
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_modular_regression_'.$run, $identity->cluster);
$this->assertSame(55439, (int) $identity->port);
$this->withoutExceptionHandling();
DB::listen(function (QueryExecuted $q) {
if ($this->measurement === null) return;
$this->measurement['queries']++;
$this->measurement['database_ms'] += $q->time;
if (!preg_match('/^\s*(select|with)\b/i', $q->sql)) throw new \RuntimeException('READ_WORKLOAD_WROTE_SQL');
$key = hash('sha256', $q->sql);
$entry = $this->queries[$key] ?? ['sql' => $q->sql, 'bindings' => $q->bindings, 'count' => 0, 'total_ms' => 0.0];
$entry['count']++; $entry['total_ms'] += $q->time; $this->queries[$key] = $entry;
if ((hrtime(true) - $this->started) / 1e6 > 15000 || $this->measurement['queries'] > 2500) throw new \RuntimeException('CASE_BUDGET_REACHED');
});
$this->report = ['date' => '2026-11-02', 'checkpoint' => '340f3bb', 'php' => PHP_VERSION, 'identity' => $identity,
'method' => 'Sequential in-process HTTP kernel requests; three measured repetitions after warm-up; fixtures rolled back; no external HTTP/browser or concurrent capacity claim.',
'bounds' => ['sql_timeout_ms' => 3000, 'case_budget_ms' => 15000, 'query_budget' => 2500], 'datasets' => []];
try {
foreach ([1000, 5000, 10000] as $size) {
DB::beginTransaction();
try {
$fixture = $this->fixture($size);
$f = $fixture['identity'];
$this->actingAs($f['manager'])->withSession(['auth_version' => $f['manager']->auth_version]);
$token = $this->postJson('/api/context', ['tenant_id' => $f['tenant'], 'company_id' => $f['company'], 'unit_id' => $f['unit'], 'module_id' => null])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context', $token);
$this->getJson('/api/module-records?per_page=1')->assertOk();
$dataset = ['clients' => $size, 'services' => count($fixture['ids']['services']), 'drafts_per_area' => count($fixture['ids']['service_drafts']),
'module_events' => DB::table('identity_audit')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->where('action', 'like', 'module.record.%')->count(),
'history_pairs_per_nonpending_record' => 4, 'statistics' => 'ANALYZE within disposable regression database; no index or schema changes', 'cases' => []];
$cases = [
['clients', 'all', 25, 1, ''], ['clients', 'all', 25, (int) ceil($size / 25), ''],
['clients', 'all', 100, 1, ''], ['clients', 'pending', 25, 1, ''],
['clients', 'associated', 25, 1, ''], ['clients', 'removed', 25, 1, ''],
['clients', 'all', 25, 1, 'LOAD-client-000017'],
['service_drafts', 'all', 25, 1, ''], ['billing_drafts', 'all', 25, 1, ''],
];
foreach ($cases as [$area, $state, $perPage, $page, $search]) {
$expected = array_values(array_filter($fixture['ids'][$area], fn ($id) => match ($state) {
'pending' => $id % 3 === 0, 'associated' => $id % 3 === 1, 'removed' => $id % 3 === 2, default => true,
}));
if ($search !== '') $expected = [$fixture['ids']['clients'][16]];
$case = $this->measure(compact('area', 'state', 'perPage', 'page', 'search'), $expected);
$dataset['cases'][] = $case;
fwrite(STDOUT, "\nLOAD ".$size.' '.$case['label'].' '.$case['status'].' '.($case['median_ms'] ?? '')."ms\n");
}
// A complete traversal with page size 100 validates ID coverage,
// not timing/capacity; it is capped to the smallest dataset.
if ($size === 1000) {
$seen = [];
for ($p = 1; $p <= 10; $p++) {
$rows = $this->getJson('/api/module-records?per_page=100&page='.$p)->assertOk()->json('records.clients');
$seen = [...$seen, ...array_column($rows, 'id')];
}
$this->assertSame($fixture['ids']['clients'], $seen);
$dataset['complete_traversal'] = ['pages' => 10, 'records' => count($seen), 'ordered_exact_coverage' => true];
}
$this->report['datasets'][] = $dataset;
} finally { $this->measurement = null; DB::rollBack(); }
}
$this->report['completed'] = true;
} finally {
$this->measurement = null;
$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($this->report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
}
}
protected function fixture(int $size, bool $uniform = false): array
{
DB::statement("SET LOCAL statement_timeout = '30s'");
$f = IdentityFixture::create('-load-'.bin2hex(random_bytes(6)));
foreach (['clients', 'services', 'service_drafts', 'billing_drafts'] as $area) {
IdentityFixture::grant($f['manager']->id, $f['tenant'], $f['company'], "$area.view", str_ends_with($area, '_drafts') ? $f['unit'] : null);
}
$ids = [];
foreach (['clients' => $size, 'services' => $uniform ? $size : 100] as $area => $n) {
DB::insert("INSERT INTO a_{$area}(tenant_id,company_id,unit_id,name,version,data,created_at,updated_at)
SELECT ?,?,NULL,? || lpad(i::text,6,'0'),1,'{}'::jsonb,now(),now() FROM generate_series(1,?) i", [$f['tenant'], $f['company'], $area === 'clients' ? 'LOAD-client-' : 'LOAD-service-', $n]);
$ids[$area] = DB::table('a_'.$area)->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
}
$draftCount = $uniform ? $size : min(1000, (int) ($size / 10));
$draftRows = [];
for ($i = 0; $i < $draftCount; $i++) {
$draftRows[] = ['tenant_id' => $f['tenant'], 'company_id' => $f['company'], 'unit_id' => $f['unit'], 'name' => 'LOAD-draft-'.$i, 'version' => 1,
'client_id' => $ids['clients'][$i], 'data' => json_encode(['currency' => 'MZN', 'tax_status' => 'pending', 'accounting_status' => 'not_processed',
'lines' => [['service_id' => $ids['services'][$i % count($ids['services'])], 'quantity' => '1', 'unit_price' => '10']]]), 'created_at' => now(), 'updated_at' => now()];
}
foreach (array_chunk($draftRows, 500) as $chunk) DB::table('a_service_drafts')->insert($chunk);
$ids['service_drafts'] = DB::table('a_service_drafts')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
foreach ($draftRows as $i => &$row) { $row['source_id'] = $ids['service_drafts'][$i]; $data = json_decode($row['data'], true); $data['source_version'] = 1; $row['data'] = json_encode($data); }
unset($row);
foreach (array_chunk($draftRows, 500) as $chunk) DB::table('a_billing_drafts')->insert($chunk);
$ids['billing_drafts'] = DB::table('a_billing_drafts')->where('tenant_id', $f['tenant'])->where('company_id', $f['company'])->orderBy('id')->pluck('id')->all();
foreach (array_keys($ids) as $area) {
DB::insert("INSERT INTO identity_audit(action,actor_id,tenant_id,company_id,unit_id,details,created_at)
SELECT CASE WHEN seq%2=1 THEN 'module.record.associated' ELSE 'module.record.disassociated' END,?,?,?,r.unit_id,
jsonb_build_object('area',?::text,'record_id',r.id,'module_id','commercial','version',1,'reason','Synthetic load history'),now()
FROM a_{$area} r CROSS JOIN generate_series(1,8) seq WHERE r.tenant_id=? AND r.company_id=? AND r.id%3<>0 ORDER BY seq,r.id",
[$f['manager']->id, $f['tenant'], $f['company'], $area, $f['tenant'], $f['company']]);
DB::insert("INSERT INTO identity_audit(action,actor_id,tenant_id,company_id,unit_id,details,created_at)
SELECT 'module.record.associated',?,?,?,r.unit_id,jsonb_build_object('area',?::text,'record_id',r.id,'module_id','commercial','version',1,'reason','Synthetic active state'),now()
FROM a_{$area} r WHERE r.tenant_id=? AND r.company_id=? AND r.id%3=1 ORDER BY r.id",
[$f['manager']->id, $f['tenant'], $f['company'], $area, $f['tenant'], $f['company']]);
}
// Noise belongs to another tenant or an unselected unit, never copied from a client.
DB::insert("INSERT INTO a_clients(tenant_id,company_id,unit_id,name,version,data,created_at,updated_at) SELECT ?,?,NULL,'LOAD-foreign-'||i,1,'{}',now(),now() FROM generate_series(1,100) i", [$f['otherTenant'], $f['foreign']]);
$hidden = $draftRows[0]; unset($hidden['source_id']); $hidden['unit_id'] = $f['hidden']; $hidden['name'] = 'LOAD-hidden';
DB::table('a_service_drafts')->insert($hidden);
foreach (['a_clients', 'a_services', 'a_service_drafts', 'a_billing_drafts', 'identity_audit'] as $table) DB::statement('ANALYZE '.$table);
return ['identity' => $f, 'ids' => $ids];
}
private function measure(array $spec, array $expected): array
{
['area' => $area, 'state' => $state, 'perPage' => $perPage, 'page' => $page, 'search' => $search] = $spec;
$out = ['label' => "$area/$state/size$perPage/page$page".($search !== '' ? '/search' : ''), 'status' => 'ok', 'samples' => []];
$plans = [];
for ($i = 0; $i < 3; $i++) {
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
$this->queries = []; $this->measurement = ['queries' => 0, 'database_ms' => 0.0];
memory_reset_peak_usage(); $memory = memory_get_usage(true); $this->started = hrtime(true);
try {
$r = $this->getJson('/api/module-records?'.http_build_query(['area' => $area, 'state' => $state, 'per_page' => $perPage, 'page' => $page, 'search' => $search]));
$elapsed = (hrtime(true) - $this->started) / 1e6;
$metrics = $this->measurement; $this->measurement = null;
$r->assertOk()->assertJsonPath('pagination.total', count($expected));
$rows = $r->json('records.'.$area);
$this->assertSame(array_slice($expected, ($page - 1) * $perPage, $perPage), array_column($rows, 'id'));
foreach ($rows as $row) $this->assertFalse($row['dependencies_restricted']);
$out['samples'][] = [...$metrics, 'elapsed_ms' => round($elapsed, 3), 'database_ms' => round($metrics['database_ms'], 3),
'peak_php_bytes' => memory_get_peak_usage(true), 'transient_php_bytes' => max(0, memory_get_peak_usage(true) - $memory), 'response_bytes' => strlen($r->getContent()), 'rows' => count($rows)];
$plans = $this->queries;
} catch (\Illuminate\Database\QueryException $e) {
$this->measurement = null; $out['status'] = 'sql_timeout_or_error'; $out['sqlstate'] = $e->errorInfo[0] ?? $e->getCode(); $out['elapsed_ms'] = round((hrtime(true) - $this->started) / 1e6, 3); break;
} catch (\RuntimeException $e) {
$this->measurement = null;
if ($e->getMessage() !== 'CASE_BUDGET_REACHED') throw $e;
$out['status'] = 'case_budget_reached'; $out['elapsed_ms'] = round((hrtime(true) - $this->started) / 1e6, 3); break;
} finally { $this->measurement = null; DB::rollBack(); }
}
if (count($out['samples']) === 3) {
$times = array_column($out['samples'], 'elapsed_ms'); sort($times);
$out['median_ms'] = $times[1]; $out['min_ms'] = $times[0]; $out['max_ms'] = $times[2];
}
uasort($plans, fn ($a, $b) => $b['total_ms'] <=> $a['total_ms']);
$out['top_query_plans'] = [];
foreach (array_slice($plans, 0, 3, true) as $signature => $query) {
DB::beginTransaction(); DB::statement("SET LOCAL statement_timeout = '3s'");
try {
$raw = DB::select('EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) '.$query['sql'], $query['bindings']);
$plan = json_decode($raw[0]->{'QUERY PLAN'}, true, 512, JSON_THROW_ON_ERROR)[0];
$nodes = []; $this->nodes($plan['Plan'], $nodes);
$out['top_query_plans'][] = ['signature' => $signature, 'query_shape' => $query['sql'], 'calls_per_request' => $query['count'],
'cumulative_ms' => round($query['total_ms'], 3), 'planning_ms' => $plan['Planning Time'], 'execution_ms' => $plan['Execution Time'], 'nodes' => $nodes];
} catch (\Illuminate\Database\QueryException $e) { $out['top_query_plans'][] = ['signature' => $signature, 'status' => 'plan_timeout_or_error', 'sqlstate' => $e->errorInfo[0] ?? $e->getCode()];
} finally { DB::rollBack(); }
}
return $out;
}
private function nodes(array $plan, array &$nodes): void
{
$nodes[] = array_intersect_key($plan, array_flip(['Node Type', 'Relation Name', 'Index Name', 'Actual Rows', 'Actual Loops', 'Rows Removed by Filter', 'Shared Hit Blocks', 'Shared Read Blocks', 'Temp Read Blocks', 'Temp Written Blocks']));
foreach ($plan['Plans'] ?? [] as $child) $this->nodes($child, $nodes);
}
}