Files
Investidor-V2/tools/verify-funding-concurrency.php

205 lines
15 KiB
PHP

<?php
// A real two-process check, restricted to this disposable loopback QA database.
use App\Models\User;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use App\Services\InvestmentOpportunityAlerts;
use App\Support\IsolatedQaDatabase;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\RecordNotFoundException;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Process\Process;
require dirname(__DIR__).'/vendor/autoload.php';
function requireCheck(bool $condition, string $message): void
{
if (! $condition) {
throw new RuntimeException($message);
}
}
try {
requireCheck(PHP_SAPI === 'cli' && getenv('APP_ENV') === 'testing'
&& getenv('DB_CONNECTION') === 'pgsql' && getenv('DB_HOST') === '127.0.0.1'
&& getenv('DB_PORT') === '55441' && preg_match('/^funding_concurrency_20261102[a-z]?_test$/D', getenv('DB_DATABASE') ?: '') === 1
&& in_array(getenv('DB_URL'), [false, ''], true), 'Explicit disposable funding QA environment required.');
$app = require dirname(__DIR__).'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
requireCheck(! $app->configurationIsCached() && IsolatedQaDatabase::allowsSchemaChanges()
&& DB::connection()->getDatabaseName() === getenv('DB_DATABASE'), 'Effective QA target rejected.');
DB::statement("SET statement_timeout = '45s'");
$service = app(FundingProgrammes::class);
requireCheck($service->installed(), 'Migrate the empty disposable database first.');
$directory = storage_path('app/private/funding-concurrency-20261102');
if (($argv[1] ?? null) === '--worker') {
$path = realpath($argv[2] ?? '');
requireCheck($path !== false && dirname($path) === realpath($directory), 'Worker context path rejected.');
$context = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
requireCheck(($context['database'] ?? null) === DB::connection()->getDatabaseName(), 'Worker database context differs.');
$task = $argv[3] ?? '';
$index = (int) ($argv[4] ?? -1);
requireCheck(in_array($index, [0, 1], true) && in_array($task, ['prepare', 'save', 'publish', 'interest', 'withdraw', 'preferences', 'cancel', 'dispatch'], true), 'Worker operation rejected.');
$actor = User::findOrFail($context['actor']);
requireCheck($actor->email === 'synthetic-funding-concurrency@example.invalid', 'Synthetic identity required.');
config(['portal.managers' => [$actor->email]]);
$application = 'funding-qa-'.$context['run'].'-'.$task.'-'.$index;
DB::select("SELECT set_config('application_name', ?, false)", [$application]);
try {
$result = match ($task) {
'preferences' => app(InvestmentOpportunityAlerts::class)->save($actor, $context['preferences']),
'cancel' => app(InvestmentOpportunityAlerts::class)->save($actor, ['version' => $context['preference_version'], 'enabled' => false]),
'dispatch' => ['delivered' => app(InvestmentOpportunityAlerts::class)->dispatchDue()],
'prepare' => app(FundingProgrammeReferences::class)->prepare($actor, 'fdel', $context['reference_hash']),
'save' => ['id' => $service->save($actor, $context['id'], ['version' => $context['version'], 'code' => 'fdel',
'reason' => 'Alteração sintética concorrente do processo '.$index.'.',
'draft' => [...$context['draft'], 'summary' => 'SINTÉTICO QA — Alteração concorrente '.$index]])],
'publish' => ['version_id' => $service->publish($actor, $context['id'], ['version' => $context['version'], 'hash' => $context['publication_hash'],
'reason' => 'Publicação sintética concorrente para ensaio isolado.', 'confirm' => true])],
'interest' => (function () use ($service, $actor, $context): array {
$service->saveInterest($actor, $context['id'], $context['published'], true);
return ['saved' => true];
})(),
'withdraw' => (function () use ($service, $actor, $context): array {
$service->withdraw($actor, $context['id'], $context['version'], 'Retirada sintética concorrente para ensaio isolado.');
return ['withdrawn' => true];
})(),
};
echo json_encode(['status' => 'ok', 'result' => $result], 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;
} catch (RecordNotFoundException $error) {
echo json_encode(['status' => 'unavailable', 'code' => 404], JSON_THROW_ON_ERROR).PHP_EOL;
}
exit(0);
}
requireCheck(($argv[1] ?? null) === '--run', 'Use --run with an explicitly configured empty QA database.');
$lock = fopen(storage_path('framework/qa-database-'.hash('sha256', '127.0.0.1:55441:'.DB::connection()->getDatabaseName()).'.lock'), 'c');
requireCheck($lock !== false && flock($lock, LOCK_EX | LOCK_NB), 'Another QA runner owns this database.');
requireCheck(DB::table('users')->count() === 0 && DB::table('funding_programmes')->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 funding concurrency', 'email' => 'synthetic-funding-concurrency@example.invalid']);
config(['portal.managers' => [$actor->email]]);
$run = bin2hex(random_bytes(5));
$context = ['actor' => $actor->id, 'run' => $run, 'database' => DB::connection()->getDatabaseName(), 'reference_hash' => app(FundingProgrammeReferences::class)->preview($actor, 'fdel')['hash']];
$checks = [];
$phase = function (string $name, array $tasks, string $heldLock) use (&$context, $directory, &$checks): array {
$path = $directory.'/'.$name.'-'.$context['run'].'.json';
file_put_contents($path, json_encode($context, JSON_THROW_ON_ERROR));
DB::beginTransaction();
if ($heldLock === 'reference') {
DB::select('select pg_advisory_xact_lock(261102, hashtext(?))', ['fdel']);
} elseif ($heldLock === 'actor') {
DB::table('users')->where('id', $context['actor'])->lockForUpdate()->firstOrFail();
} elseif ($heldLock === 'preferences') {
DB::table('investment_alert_preferences')->where('user_id', $context['actor'])->lockForUpdate()->firstOrFail();
} else {
DB::table('funding_programmes')->where('id', $context['id'])->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;
$application = 'funding-qa-'.$context['run'].'-'.$task.'-'.$index;
$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'", [$application]);
if ($waiting) {
$observed[] = (array) $waiting;
break;
}
requireCheck($process->isRunning(), 'Worker exited before overlapping lock wait: '.$name.' '.$process->getErrorOutput());
usleep(100000);
} while (microtime(true) < $deadline);
requireCheck($waiting !== null, 'No actual lock overlap observed: '.$name);
}
DB::commit();
$results = [];
foreach ($processes as $process) {
$process->wait();
requireCheck($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' => count($results), 'lock_overlap_verified' => true], JSON_THROW_ON_ERROR).PHP_EOL;
return $results;
};
$results = $phase('reference', ['prepare', 'prepare'], 'reference');
requireCheck(array_column($results, 'status') === ['ok', 'ok']
&& count(array_filter(array_column(array_column($results, 'result'), 'created'))) === 1, 'Reference preparation duplicated or failed.');
requireCheck(DB::table('funding_programmes')->count() === 1 && DB::table('audit_events')->where('action', 'reference_prepared')->count() === 1, 'Reference history duplicated.');
$row = DB::table('funding_programmes')->where('code', 'fdel')->firstOrFail();
$context = [...$context, 'id' => $row->id, 'version' => (int) $row->version, 'draft' => json_decode($row->draft, true)];
$results = $phase('draft', ['save', 'save'], 'reference');
$statuses = array_column($results, 'status');
sort($statuses);
requireCheck($statuses === ['conflict', 'ok'] && (int) DB::table('funding_programmes')->where('id', $row->id)->value('version') === 2, 'Concurrent stale draft was not rejected.');
$plan = $service->preview($actor, $row->id);
$context = [...$context, 'version' => $plan['version'], 'publication_hash' => $plan['hash']];
$results = $phase('publication', ['publish', 'publish'], 'programme');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && $results[0]['result']['version_id'] === $results[1]['result']['version_id']
&& DB::table('funding_programme_versions')->count() === 1, 'Concurrent publication duplicated.');
$context['published'] = $results[0]['result']['version_id'];
$results = $phase('interest', ['interest', 'interest'], 'actor');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && DB::table('funding_programme_interests')->count() === 1
&& DB::table('audit_events')->where('action', 'interest_saved')->count() === 1, 'Concurrent interest duplicated.');
$alerts = app(InvestmentOpportunityAlerts::class);
requireCheck($alerts->fundingAvailable(), 'Funding alerts migration required for generator concurrency.');
$input = ['version' => 0, 'enabled' => true, 'frequency' => 'as_available', 'channel' => 'in_app', 'consent' => true, 'sources' => ['funding'], 'criteria' => []];
$alerts->save($actor, $input);
$publishNext = function (string $label) use ($service, $actor, &$context): void {
$row = DB::table('funding_programmes')->where('id', $context['id'])->firstOrFail();
$draft = json_decode($row->draft, true);
$draft['summary'] = 'SYNTHETIC QA concurrent alert publication '.$label;
$service->save($actor, $row->id, ['code' => 'fdel', 'version' => $row->version, 'reason' => 'Synthetic publication for generator race validation.', 'draft' => $draft]);
$plan = $service->preview($actor, $row->id);
$context['published'] = $service->publish($actor, $row->id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'Synthetic publication for generator race validation.']);
$context['version'] = $plan['version'];
};
$publishNext('duplicate-generators');
$results = $phase('duplicate_generators', ['dispatch', 'dispatch'], 'preferences');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && array_sum(array_column(array_column($results, 'result'), 'delivered')) === 1
&& DB::table('investment_notifications')->count() === 1 && DB::table('investment_funding_alert_seen')->whereNotNull('notification_id')->count() === 1, 'Duplicate generator delivery.');
$context['preferences'] = [...$input, 'version' => 1, 'frequency' => 'daily'];
$results = $phase('duplicate_preferences', ['preferences', 'preferences'], 'preferences');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && $results[0]['result']['version'] === 2 && $results[1]['result']['version'] === 2
&& DB::table('audit_events')->where('source_system', 'investment-alerts')->where('action', 'preferences_saved')->count() === 2, 'Identical preference requests duplicated revisions.');
$publishNext('cancel-before-generator');
DB::table('investment_alert_preferences')->where('user_id', $actor->id)->update(['next_run_at' => now()]);
$context['preference_version'] = 2;
$results = $phase('cancel_before_generator', ['cancel', 'dispatch'], 'preferences');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && $results[1]['result']['delivered'] === 0
&& ! $alerts->preferences($actor->id)['enabled'] && DB::table('investment_notifications')->count() === 1, 'Generator ignored effective cancellation.');
$alerts->save($actor, [...$input, 'version' => 3]);
$publishNext('generator-before-cancel');
$context['preference_version'] = 4;
$results = $phase('generator_before_cancel', ['dispatch', 'cancel'], 'preferences');
requireCheck(array_column($results, 'status') === ['ok', 'ok'] && $results[0]['result']['delivered'] === 1
&& ! $alerts->preferences($actor->id)['enabled'] && DB::table('investment_notifications')->count() === 2
&& $alerts->dispatchDue() === 0, 'Generator/cancellation race lost serialization or generated after cancellation.');
$results = $phase('withdrawal_vs_interest', ['withdraw', 'interest'], 'programme');
requireCheck($results[0]['status'] === 'ok' && in_array($results[1]['status'], ['ok', 'unavailable'], true)
&& DB::table('funding_programmes')->where('id', $row->id)->value('published_version_id') === null
&& DB::table('funding_programme_interests')->count() === 1, 'Withdrawal/interest race violated preservation.');
echo json_encode(['result' => 'passed', 'database' => DB::connection()->getDatabaseName(), 'synthetic' => true, 'phases' => $checks,
'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 QA process failure.'], JSON_THROW_ON_ERROR).PHP_EOL);
exit(1);
}