152 lines
10 KiB
PHP
152 lines
10 KiB
PHP
<?php
|
|
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
require dirname(__DIR__).'/vendor/autoload.php';
|
|
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
|
|
$config = DB::connection('pgsql')->getConfig();
|
|
unset($config['name']);
|
|
if ($config['host'] !== '127.0.0.1') {
|
|
throw new LogicException('Prova de fornecimento apenas em servidor local.');
|
|
}
|
|
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
|
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $database)) {
|
|
throw new LogicException('Nome de base descartável inválido.');
|
|
}
|
|
config(['database.connections.supply_proof_admin' => [...$config, 'database' => 'postgres', 'url' => null]]);
|
|
$admin = DB::connection('supply_proof_admin');
|
|
$admin->statement('CREATE DATABASE "'.$database.'"');
|
|
try {
|
|
$environment = [
|
|
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'],
|
|
'DB_DATABASE' => $database, 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'],
|
|
];
|
|
$process = new Process([PHP_BINARY, 'vendor/bin/pest', '--compact', 'tests/Feature/SupplyPlanningTest.php', 'tests/Feature/SupplyCoordinationTest.php', 'tests/Feature/SupplySuggestionsTest.php', 'tests/Feature/SupplyHistoryTest.php', 'tests/Feature/InventoryLotsTest.php', 'tests/Feature/InventoryCountsTest.php', 'tests/Feature/InventoryDispositionsTest.php', 'tests/Feature/InventoryLotPolicyTest.php', 'tests/Feature/InventoryReversalsTest.php'], dirname(__DIR__), $environment, timeout: 120);
|
|
$process->run();
|
|
echo $process->getOutput();
|
|
if (! $process->isSuccessful()) {
|
|
echo $process->getErrorOutput();
|
|
throw new RuntimeException('Prova PostgreSQL não aprovada.');
|
|
}
|
|
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', 'prepare'], dirname(__DIR__), $environment, timeout: 20);
|
|
$fixture->mustRun();
|
|
$ids = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
|
config(['database.connections.supply_proof_read' => [...$config, 'database' => $database, 'url' => null]]);
|
|
$proofDb = DB::connection('supply_proof_read');
|
|
$races = [];
|
|
foreach (['same_reference', 'same_operation', 'single_conversion', 'conversion_retry'] as $scenario) {
|
|
$conversion = in_array($scenario, ['single_conversion', 'conversion_retry'], true);
|
|
if ($conversion) {
|
|
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', 'prepare_suggestion'], dirname(__DIR__), $environment, timeout: 20);
|
|
$fixture->mustRun();
|
|
$ids = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
|
}
|
|
$data = [...$ids, 'reference' => 'RACE-'.$scenario, 'operation_key' => (string) Str::uuid()];
|
|
$beforeRequests = $proofDb->table('requisitions')->where('tenant_id', $ids['tenant'])->count();
|
|
$first = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $conversion ? 'hold_suggestion' : 'hold', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
|
$first->start();
|
|
$deadline = microtime(true) + 5;
|
|
while (! str_contains($first->getOutput(), 'locked') && $first->isRunning() && microtime(true) < $deadline) {
|
|
usleep(20000);
|
|
}
|
|
if (! str_contains($first->getOutput(), 'locked')) {
|
|
throw new RuntimeException('Primeiro worker não adquiriu o lock.');
|
|
}
|
|
if (in_array($scenario, ['same_reference', 'single_conversion'], true)) {
|
|
$data['operation_key'] = (string) Str::uuid();
|
|
}
|
|
$second = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $conversion ? 'convert_suggestion' : 'write', json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
|
$second->start();
|
|
$blocked = false;
|
|
$deadline = microtime(true) + 5;
|
|
while ($first->isRunning() && microtime(true) < $deadline) {
|
|
$blocked = $proofDb->selectOne("select exists(select 1 from pg_stat_activity where datname = ? and wait_event_type = 'Lock') as blocked", [$database])->blocked;
|
|
if ($blocked) {
|
|
break;
|
|
}
|
|
usleep(20000);
|
|
}
|
|
$first->wait();
|
|
$second->wait();
|
|
$a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 16, JSON_THROW_ON_ERROR);
|
|
$b = json_decode(trim($second->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
|
$expected = in_array($scenario, ['same_reference', 'single_conversion'], true) ? $b['result'] === 'rejected' : $b['result'] === 'saved' && $b['id'] === $a['id'];
|
|
if (! $blocked || $a['result'] !== 'saved' || ! $expected) {
|
|
throw new RuntimeException('Concorrência ou idempotência não demonstradas.');
|
|
}
|
|
if ($conversion && $proofDb->table('requisitions')->where('tenant_id', $ids['tenant'])->count() !== $beforeRequests + 1) {
|
|
throw new RuntimeException('A conversão não criou exactamente uma requisição.');
|
|
}
|
|
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
|
|
}
|
|
foreach (['lot_double_issue', 'lot_identification_release', 'lot_double_transfer', 'lot_double_adjustment', 'double_supplier_return', 'lot_policy_stock_change', 'double_stock_reversal'] as $scenario) {
|
|
$adjustment = $scenario === 'lot_double_adjustment';
|
|
$return = $scenario === 'double_supplier_return';
|
|
$policy = $scenario === 'lot_policy_stock_change';
|
|
$reversal = $scenario === 'double_stock_reversal';
|
|
$fixtureMode = $reversal ? 'prepare_reversal' : ($policy ? 'prepare_policy' : ($return ? 'prepare_return' : ($adjustment ? 'prepare_count' : 'prepare_lots')));
|
|
$fixture = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $fixtureMode], dirname(__DIR__), $environment, timeout: 20);
|
|
$fixture->mustRun();
|
|
$data = json_decode($fixture->getOutput(), true, 16, JSON_THROW_ON_ERROR);
|
|
$data['operation_key'] = (string) Str::uuid();
|
|
$data['selected'] = 0;
|
|
$transfer = $scenario === 'lot_double_transfer';
|
|
$issue = $scenario === 'lot_double_issue' || $transfer || $policy;
|
|
$firstMode = $reversal ? 'hold_stock_reversal' : ($return ? 'hold_disposition' : ($adjustment ? 'hold_lot_adjust' : ($issue ? ($transfer ? 'hold_lot_transfer' : 'hold_lot_issue') : 'hold_lot_identify')));
|
|
$first = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $firstMode, json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
|
$first->start();
|
|
$deadline = microtime(true) + 5;
|
|
while (! str_contains($first->getOutput(), 'locked') && $first->isRunning() && microtime(true) < $deadline) {
|
|
usleep(20000);
|
|
}
|
|
if (! str_contains($first->getOutput(), 'locked')) {
|
|
throw new RuntimeException('Worker de lotes não adquiriu o lock.');
|
|
}
|
|
$data['operation_key'] = (string) Str::uuid();
|
|
$data['selected'] = 1;
|
|
$secondMode = $reversal ? 'stock_reversal' : ($policy ? 'lot_activate' : ($return ? 'disposition' : ($adjustment ? 'lot_adjust' : ($issue ? ($transfer ? 'lot_transfer' : 'lot_issue') : 'lot_release'))));
|
|
$second = new Process([PHP_BINARY, 'tests/Concurrency/supply-worker.php', $secondMode, json_encode($data, JSON_THROW_ON_ERROR)], dirname(__DIR__), $environment, timeout: 20);
|
|
$second->start();
|
|
$blocked = false;
|
|
$deadline = microtime(true) + 5;
|
|
while ($first->isRunning() && microtime(true) < $deadline) {
|
|
$blocked = $proofDb->selectOne("select exists(select 1 from pg_stat_activity where datname = ? and wait_event_type = 'Lock') as blocked", [$database])->blocked;
|
|
if ($blocked) {
|
|
break;
|
|
}
|
|
usleep(20000);
|
|
}
|
|
$first->wait();
|
|
$second->wait();
|
|
$a = json_decode(trim(str_replace('locked', '', $first->getOutput())), true, 16, JSON_THROW_ON_ERROR);
|
|
$b = json_decode(trim($second->getOutput()), true, 16, JSON_THROW_ON_ERROR);
|
|
if (! $blocked || $a['result'] !== 'saved' || $b['result'] !== 'rejected') {
|
|
throw new RuntimeException('Corrida de lotes não preservou a conferência.');
|
|
}
|
|
if ($issue || $adjustment || $return || $reversal) {
|
|
$book = $proofDb->table('stock_movements')->where('tenant_id', $data['tenant'])->sum('quantity_milli');
|
|
$lots = $proofDb->table('inventory_lot_allocations')->where('tenant_id', $data['tenant'])->sum('quantity_milli');
|
|
$expectedTotal = $reversal ? 10000 : ($return ? 13000 : ($adjustment ? 9000 : ($transfer ? 10000 : 3000)));
|
|
if ((int) $book !== $expectedTotal || (int) $lots !== ($return || $reversal ? 0 : $expectedTotal)) {
|
|
throw new RuntimeException('Saída concorrente alterou o saldo indevidamente.');
|
|
}
|
|
if ($transfer && ((int) $proofDb->table('stock_movements')->where('tenant_id', $data['tenant'])->where('warehouse_id', $data['warehouse'])->sum('quantity_milli') !== 3000
|
|
|| (int) $proofDb->table('inventory_lot_allocations')->where('tenant_id', $data['tenant'])->where('warehouse_id', $data['destination'])->sum('quantity_milli') !== 7000)) {
|
|
throw new RuntimeException('Transferência concorrente não conservou as duas pontas.');
|
|
}
|
|
}
|
|
$races[] = ['scenario' => $scenario, 'real_lock_contention' => true, 'result' => 'passed'];
|
|
}
|
|
file_put_contents(dirname(__DIR__).'/docs/proofs/supply-coordination-pg.json', json_encode(['checked_at' => gmdate('c'), 'database' => 'fresh disposable PostgreSQL', 'tests' => ['SupplyPlanningTest', 'SupplyCoordinationTest', 'SupplySuggestionsTest', 'SupplyHistoryTest', 'InventoryLotsTest', 'InventoryCountsTest', 'InventoryDispositionsTest', 'InventoryLotPolicyTest', 'InventoryReversalsTest'], 'result' => 'passed', 'races' => $races, 'persistent_data_changed' => false, 'output' => trim($process->getOutput())], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
echo "Eleven PostgreSQL races passed with real lock contention.\n";
|
|
} finally {
|
|
DB::purge('supply_proof_read');
|
|
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$database]);
|
|
$admin->statement('DROP DATABASE "'.$database.'"');
|
|
}
|