119 lines
10 KiB
PHP
119 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Tests\Benchmarks;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\Support\IdentityFixture;
|
|
|
|
require_once __DIR__.'/ModuleAssociationConcurrencyTest.php';
|
|
|
|
final class ModuleAssociationReleaseTest extends ModuleAssociationConcurrencyTest
|
|
{
|
|
// New cases only; the inherited 440-read benchmark is not executed.
|
|
public function test_synthetic_volume_and_query_plans(): void
|
|
{
|
|
$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);
|
|
$tag = bin2hex(random_bytes(6)); $this->directory = base_path('../private/association-release-'.$tag); mkdir($this->directory, 0700, true);
|
|
$before = $this->baseline(); $scope = IdentityFixture::create('-release-'.$tag);
|
|
foreach (['clients.view', 'clients.edit'] as $permission) IdentityFixture::grant($scope['manager']->id, $scope['tenant'], $scope['company'], $permission);
|
|
$cases = ['rollback_then_complete', 'commit_then_stale_revision', 'commit_then_same_token', 'commit_record_change_then_stale_version']; $ids = [];
|
|
foreach ($cases as $name) $ids[$name] = DB::table('a_clients')->insertGetId(['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => null,
|
|
'name' => 'Synthetic association release '.$name, 'version' => 1, 'data' => '{}', 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->actingAs($scope['manager'])->withSession(['auth_version' => $scope['manager']->auth_version]);
|
|
$context = $this->postJson('/api/context', ['tenant_id' => $scope['tenant'], 'company_id' => $scope['company'], 'unit_id' => $scope['unit'], 'module_id' => null])->assertOk()->json('context.token');
|
|
$this->withHeader('X-SGF-Context', $context);
|
|
DB::commit();
|
|
file_put_contents($this->directory.'/manifest.json', json_encode(['synthetic_only' => true, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'], 'actor' => $scope['manager']->id, 'clients' => $ids], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
$report = ['date' => '2026-11-02', 'run_tag' => $tag, 'identity' => $identity, 'source_sha256' => hash_file('sha256', base_path('app/Modules/Identity/Services/ModuleAssociationReview.php')),
|
|
'fixture_policy' => 'Four new QA-only clients committed for worker visibility; fixture and synthetic results retained in regression DB with private manifest.', 'cases' => []];
|
|
try {
|
|
foreach ($cases as $number => $name) {
|
|
$id = $ids[$name]; $input = ['area' => 'clients', 'id' => $id, 'version' => 1, 'module_id' => 'commercial', 'operation' => 'associate', 'reason' => 'Synthetic transaction release '.$name];
|
|
$first = $this->postJson('/api/module-records/preview', $input)->assertOk()->assertJsonPath('before', [])->json('review_token');
|
|
$second = $this->postJson('/api/module-records/preview', $input)->assertOk()->json('review_token');
|
|
$token = $name === 'commit_then_same_token' ? $first : $second;
|
|
$parent = (int) DB::selectOne('SELECT pg_backend_pid() AS pid')->pid;
|
|
DB::beginTransaction(); $job = null; $released = false; $firstEvent = null;
|
|
try {
|
|
if ($name === 'commit_record_change_then_stale_version') {
|
|
DB::table('companies')->where('tenant_id', $scope['tenant'])->where('id', $scope['company'])->lockForUpdate()->firstOrFail();
|
|
DB::table('a_clients')->where('id', $id)->update(['version' => 2, 'name' => 'Synthetic version changed during wait']);
|
|
} else {
|
|
$firstEvent = $this->postJson('/api/module-records', ['review_token' => $first])->assertOk()->assertJsonPath('replayed', false)->json('event_id');
|
|
}
|
|
$application = 'sgf_assoc_release_'.$tag.'_'.$number; $gate = $this->directory.'/go-'.$number;
|
|
$job = $this->launch(['mode' => 'confirm', 'expected_result' => true, 'lock_timeout_ms' => 8000,
|
|
'actor' => $scope['manager']->id, 'tenant' => $scope['tenant'], 'company' => $scope['company'], 'unit' => $scope['unit'],
|
|
'context' => $context, 'review_token' => $token, 'go' => $gate, 'application_name' => $application]);
|
|
$this->ready([$job]); file_put_contents($gate, 'go');
|
|
$wait = $this->waitForCompanyLock($application, $parent);
|
|
usleep(200000);
|
|
if ($name === 'rollback_then_complete') DB::rollBack(); else DB::commit();
|
|
$released = true;
|
|
$result = $this->collect($job)['confirmation'];
|
|
$events = DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])
|
|
->where('action', 'like', 'module.record.%')->whereRaw("details::jsonb->>'area' = 'clients'")->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id])->count();
|
|
$version = (int) DB::table('a_clients')->where('id', $id)->value('version');
|
|
if (in_array($name, ['rollback_then_complete', 'commit_then_same_token'], true)) {
|
|
$this->assertSame('completed', $result['status']); $this->assertTrue($result['result']['ok']);
|
|
$this->assertSame($name === 'commit_then_same_token', $result['result']['replayed']);
|
|
$this->assertSame(1, $events); $this->assertSame(1, $version);
|
|
$replay = $this->postJson('/api/module-records', ['review_token' => $token])->assertOk()->assertJsonPath('replayed', true)->json('event_id');
|
|
$this->assertSame((int) $result['result']['event_id'], (int) $replay);
|
|
} else {
|
|
$this->assertSame('refused', $result['status']); $this->assertSame(409, $result['http_status']);
|
|
$this->postJson('/api/module-records', ['review_token' => $token])->assertConflict();
|
|
$this->postJson('/api/module-records/preview', [...$input, 'version' => $version])->assertOk();
|
|
$this->assertSame($name === 'commit_record_change_then_stale_version' ? 0 : 1, $events);
|
|
$this->assertSame($name === 'commit_record_change_then_stale_version' ? 2 : 1, $version);
|
|
if ($name === 'commit_then_stale_revision') $this->postJson('/api/module-records', ['review_token' => $first])->assertOk()->assertJsonPath('replayed', true);
|
|
}
|
|
$final = DB::table('identity_audit')->where('tenant_id', $scope['tenant'])->where('company_id', $scope['company'])->where('action', 'like', 'module.record.%')
|
|
->whereRaw("details::jsonb->>'area' = 'clients'")->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id])->count();
|
|
$this->assertSame($events, $final, 'Retry must not append another decision');
|
|
$report['cases'][] = ['case' => $name, 'release' => $name === 'rollback_then_complete' ? 'rollback' : 'commit', 'wait' => $wait,
|
|
'second_status' => $result['status'], 'second_http_status' => $result['http_status'] ?? 200, 'second_replayed' => $result['result']['replayed'] ?? null,
|
|
'elapsed_ms' => $result['elapsed_ms'], 'record_version_after' => $version, 'decision_events_after_retry' => $final,
|
|
'first_event_persisted' => $firstEvent === null ? null : DB::table('identity_audit')->where('id', $firstEvent)->exists(), 'duplicate_decision' => false];
|
|
fwrite(STDOUT, "\nRELEASE $name status=".($result['http_status'] ?? 200)." events=$final version=$version\n");
|
|
} finally {
|
|
if (!$released && $job !== null && is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
|
while (DB::transactionLevel() > 0) DB::rollBack();
|
|
}
|
|
}
|
|
$report['completed'] = true;
|
|
} finally {
|
|
foreach ($this->jobs as $job) if (is_resource($job['process'])) { proc_terminate($job['process']); proc_close($job['process']); }
|
|
while (DB::transactionLevel() > 0) DB::rollBack();
|
|
$report['previous_qa_rows_and_index_catalog_preserved'] = $this->baseline($before) === $before;
|
|
$this->assertTrue($report['previous_qa_rows_and_index_catalog_preserved']);
|
|
$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);
|
|
DB::beginTransaction();
|
|
}
|
|
}
|
|
|
|
private function waitForCompanyLock(string $application, int $parent): array
|
|
{
|
|
$deadline = microtime(true) + 5;
|
|
do {
|
|
DB::select('SELECT pg_stat_clear_snapshot()');
|
|
$row = DB::selectOne("SELECT pid,wait_event_type,wait_event,to_json(pg_blocking_pids(pid)) AS blockers,query FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'", [$application]);
|
|
if ($row) {
|
|
$this->assertContains($parent, json_decode($row->blockers, true));
|
|
$this->assertStringContainsString('"companies"', $row->query); $this->assertMatchesRegularExpression('/for update/i', $row->query);
|
|
$locks = DB::select('SELECT locktype,mode FROM pg_locks WHERE pid=? AND NOT granted', [$row->pid]);
|
|
return ['actual_wait_observed' => true, 'blocking_parent_verified' => true, 'statement_table' => 'companies', 'statement_for_update' => true,
|
|
'wait_event_type' => $row->wait_event_type, 'wait_event' => $row->wait_event, 'ungranted_locks' => $locks];
|
|
}
|
|
usleep(25000);
|
|
} while (microtime(true) < $deadline);
|
|
$this->fail('The second confirmation did not wait on the expected company lock');
|
|
}
|
|
}
|