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.'"'); }