165 lines
12 KiB
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);
|
|
}
|