193 lines
14 KiB
PHP
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);
|
|
}
|
|
}
|