Files
Investidor-V2/tools/verify-education-draft-concurrency.php

165 lines
12 KiB
PHP

<?php
// Real process concurrency, exclusively against an empty disposable education QA database.
use App\Models\Course;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Skill;
use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationDeclarationDrafts;
use App\Support\IsolatedQaDatabase;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Process\Process;
require dirname(__DIR__).'/vendor/autoload.php';
function verifyEducation(bool $condition, string $message): void
{
if (! $condition) {
throw new RuntimeException($message);
}
}
try {
verifyEducation(PHP_SAPI === 'cli' && getenv('APP_ENV') === 'testing' && getenv('DB_CONNECTION') === 'pgsql'
&& getenv('DB_HOST') === '127.0.0.1' && getenv('DB_PORT') === '55441'
&& preg_match('/^education_draft_concurrency_20261102[a-z]?_test$/D', getenv('DB_DATABASE') ?: '') === 1
&& in_array(getenv('DB_URL'), [false, ''], true), 'Explicit disposable education QA environment required.');
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
verifyEducation(! $app->configurationIsCached() && IsolatedQaDatabase::allowsSchemaChanges()
&& DB::connection()->getDatabaseName() === getenv('DB_DATABASE'), 'Effective education QA target rejected.');
DB::statement("SET statement_timeout = '45s'");
$drafts = app(EducationDeclarationDrafts::class);
verifyEducation($drafts->installed(), 'Migrate the disposable database first.');
$directory = storage_path('app/private/education-draft-concurrency-20261102');
if (($argv[1] ?? null) === '--worker') {
$path = realpath($argv[2] ?? '');
verifyEducation($path !== false && dirname($path) === realpath($directory), 'Worker context path rejected.');
$context = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
verifyEducation($context['database'] === DB::connection()->getDatabaseName() && in_array($context['kind'], ['demands', 'capacities'], true), 'Worker database/kind differs.');
$task = $argv[3] ?? '';
$index = (int) ($argv[4] ?? -1);
verifyEducation(in_array($index, [0, 1], true) && in_array($task, ['create', 'save', 'submit'], true), 'Worker task rejected.');
$actor = User::findOrFail($context['actor']);
verifyEducation($actor->email === 'synthetic-education-concurrency@example.invalid', 'Synthetic identity required.');
config(['education.enabled' => true, 'portal.managers' => [$actor->email]]);
DB::select("SELECT set_config('application_name', ?, false)", ['edu-qa-'.$context['run'].'-'.$task.'-'.$index]);
$input = ['version_no' => $context['version_no'], 'payload' => $context['payload'], 'client_key' => $context['client_key']];
if ($task === 'save') {
$input['payload']['qualification'] = 'SYNTHETIC concurrent edit '.$context['phase'].' '.$index;
}
try {
$result = $task === 'submit' ? $drafts->submit($actor, $context['kind'], $context['id'], $input)
: $drafts->save($actor, $context['kind'], $task === 'create' ? null : $context['id'], $input);
echo json_encode(['status' => 'ok', 'id' => $result['id'], 'version_no' => $result['version_no'], 'state' => $result['state'],
'record_id' => $result['record_id'], 'payload_sha256' => hash('sha256', json_encode($result['payload'], JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR).PHP_EOL;
} catch (HttpExceptionInterface $error) {
echo json_encode(['status' => $error->getStatusCode() === 409 ? 'conflict' : 'http_error', 'code' => $error->getStatusCode()], JSON_THROW_ON_ERROR).PHP_EOL;
}
exit(0);
}
verifyEducation(($argv[1] ?? null) === '--run', 'Explicit --run required.');
$lock = fopen(storage_path('framework/qa-database-'.hash('sha256', '127.0.0.1:55441:'.DB::connection()->getDatabaseName()).'.lock'), 'c');
verifyEducation($lock !== false && flock($lock, LOCK_EX | LOCK_NB), 'Another runner owns this QA database.');
foreach (['users', 'education_declaration_drafts', 'labour_demands', 'training_capacities'] as $table) {
verifyEducation(DB::table($table)->count() === 0, 'Disposable database must be empty; existing records will not be reset.');
}
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
$actor = User::factory()->create(['name' => 'SYNTHETIC QA education concurrency', 'email' => 'synthetic-education-concurrency@example.invalid']);
config(['education.enabled' => true, 'portal.managers' => [$actor->email]]);
$province = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $province, 'name' => 'SYNTHETIC QA province', 'code' => $province, 'location_type' => 'province']);
$project = Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC QA project', 'status' => 'draft']);
$provider = TrainingProvider::create(['name' => 'SYNTHETIC QA provider', 'status' => 'candidate']);
$course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC QA course', 'status' => 'draft']);
$base = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC QA occupation'])->id,
'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC QA skill'])->id, 'geo_location_id' => $province,
'qualification' => 'SYNTHETIC QA original qualification', 'quantity' => 15, 'period_start' => today()->addMonth()->toDateString(),
'period_end' => today()->addMonths(2)->toDateString(), 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'source' => 'SYNTHETIC QA only; not actual workforce or training data.'];
$context = ['run' => bin2hex(random_bytes(5)), 'database' => DB::connection()->getDatabaseName(), 'actor' => $actor->id];
$checks = [];
$phase = function (string $name, array $tasks) use (&$context, &$checks, $directory): array {
$path = $directory.'/'.$context['run'].'-'.$name.'.json';
file_put_contents($path, json_encode([...$context, 'phase' => $name], JSON_THROW_ON_ERROR));
DB::beginTransaction();
DB::table('users')->where('id', $context['actor'])->lockForUpdate()->firstOrFail();
$processes = $observed = [];
foreach ($tasks as $index => $task) {
$process = new Process([PHP_BINARY, __FILE__, '--worker', $path, $task, (string) $index], dirname(__DIR__));
$process->setTimeout(60);
$process->start();
$processes[] = $process;
$deadline = microtime(true) + 40;
do {
DB::select('SELECT pg_stat_clear_snapshot()');
$waiting = DB::selectOne("SELECT pid,wait_event,pg_blocking_pids(pid) AS blockers FROM pg_stat_activity WHERE datname=current_database() AND application_name=? AND wait_event_type='Lock'", ['edu-qa-'.$context['run'].'-'.$task.'-'.$index]);
if ($waiting) {
$observed[] = (array) $waiting;
break;
}
verifyEducation($process->isRunning(), 'Worker exited before observed overlap: '.$name.' '.$process->getErrorOutput());
usleep(100000);
} while (microtime(true) < $deadline);
verifyEducation($waiting !== null, 'No actual overlapping lock wait: '.$name);
}
DB::commit();
$results = [];
foreach ($processes as $process) {
$process->wait();
verifyEducation($process->isSuccessful(), 'Worker failed: '.$name.' '.$process->getErrorOutput());
$results[] = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR);
}
$checks[$name] = ['overlap_observed' => $observed, 'results' => $results];
echo json_encode(['phase' => $name, 'workers' => 2, 'lock_overlap_verified' => true], JSON_THROW_ON_ERROR).PHP_EOL;
return $results;
};
foreach (['demands', 'capacities'] as $kind) {
$context = [...$context, 'kind' => $kind, 'version_no' => 0, 'client_key' => (string) Str::uuid(), 'payload' => $kind === 'demands'
? [...$base, 'project_id' => $project->id, 'employer' => 'SYNTHETIC QA employer'] : [...$base, 'course_id' => $course->id]];
$result = $phase($kind.'_duplicate_creation', ['create', 'create']);
verifyEducation(array_column($result, 'status') === ['ok', 'ok'] && $result[0]['id'] === $result[1]['id'] && $result[0]['version_no'] === 1 && $result[1]['version_no'] === 1, 'Creation duplicated draft or revision.');
$context = [...$context, 'id' => $result[0]['id'], 'version_no' => 1];
$result = $phase($kind.'_conflicting_edits', ['save', 'save']);
$statuses = array_column($result, 'status');
sort($statuses);
verifyEducation($statuses === ['conflict', 'ok'], 'Concurrent stale edit not rejected.');
$saved = $drafts->show($actor, $kind, $context['id']);
verifyEducation($saved['version_no'] === 2 && $saved['payload']['qualification'] === 'SYNTHETIC concurrent edit '.$kind.'_conflicting_edits 0', 'Winning edit not preserved.');
$context = [...$context, 'version_no' => 2, 'payload' => $saved['payload']];
$result = $phase($kind.'_duplicate_submission', ['submit', 'submit']);
verifyEducation(array_column($result, 'status') === ['ok', 'ok'] && $result[0]['record_id'] === $result[1]['record_id'] && $result[0]['state'] === 'submitted' && $result[1]['version_no'] === 3, 'Duplicate submitted record.');
$table = $kind === 'demands' ? 'labour_demands' : 'training_capacities';
verifyEducation(DB::table($table)->count() === 1, 'Duplicate business submission.');
foreach ([['save', 'submit'], ['submit', 'save']] as $order) {
$created = $drafts->save($actor, $kind, null, ['version_no' => 0, 'client_key' => (string) Str::uuid(), 'payload' => $context['payload']]);
$context = [...$context, 'id' => $created['id'], 'version_no' => 1];
$before = DB::table($table)->count();
$result = $phase($kind.'_'.implode('_before_', $order), $order);
verifyEducation(array_column($result, 'status') === ['ok', 'conflict'], 'Save/submission order not respected.');
$current = $drafts->show($actor, $kind, $context['id']);
$submitted = $order[0] === 'submit';
verifyEducation($current['state'] === ($submitted ? 'submitted' : 'draft') && $current['version_no'] === 2
&& DB::table($table)->count() === $before + ($submitted ? 1 : 0), 'Race lost draft state or duplicated declaration.');
}
}
verifyEducation(DB::table('education_declaration_drafts')->count() === 6 && DB::table('labour_demands')->count() === 2 && DB::table('training_capacities')->count() === 2, 'Final counts differ.');
verifyEducation(DB::table('education_declaration_drafts')->where('state', 'submitted')->whereNull('reference_snapshot')->count() === 0
&& DB::table('audit_events')->where('source_system', 'education-drafts')->where('action', 'draft_submitted')->count() === 4, 'Submission snapshots/audit lost.');
echo json_encode(['result' => 'passed', 'database' => DB::connection()->getDatabaseName(), 'synthetic' => true, 'phases' => $checks,
'counts' => ['drafts' => 6, 'labour_demands' => 2, 'training_capacities' => 2, 'submission_audits' => 4], 'active_writes' => 0, 'external_messages' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
} catch (Throwable $error) {
if (isset($app) && DB::connection()->transactionLevel() > 0) {
DB::rollBack();
}
fwrite(STDERR, json_encode(['result' => 'failed', 'exception' => $error::class, 'code' => $error->getCode(), 'message' => $error instanceof RuntimeException ? $error->getMessage() : 'Inspect isolated worker failure.'], JSON_THROW_ON_ERROR).PHP_EOL);
exit(1);
}