'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) { putenv($key.'='.$value); $_ENV[$key] = $_SERVER[$key] = $value; } $app = require __DIR__.'/../../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); require __DIR__.'/../Support/ReceiptFixture.php'; $original = DB::connection(); if ($original->getDriverName() !== 'pgsql') { throw new LogicException('Este ensaio exige a ligação PostgreSQL local.'); } $originalDatabase = $original->getDatabaseName(); $effectiveConfig = $original->getConfig(); $admin = $original->getPdo(); $database = 'compras_receipts_test_'.bin2hex(random_bytes(6)); if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) { throw new LogicException('Nome de base de testes não autorizado.'); } $liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn(); $created = false; $processes = []; $report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []]; try { $admin->exec('CREATE DATABASE "'.$database.'"'); $created = true; $isolated = $effectiveConfig; $isolated['url'] = null; $isolated['database'] = $database; // Replace both PostgreSQL aliases in this process and discard any schema facade // resolved by providers while the live configuration was being read. config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]); DB::purge('pgsql'); DB::setDefaultConnection('receipt_concurrency'); Schema::clearResolvedInstance('db.schema'); app()->forgetInstance('db.schema'); if (DB::selectOne('select current_database() as name')->name !== $database) { throw new LogicException('Migração recusada: ligação não isolada.'); } if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) { throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.'); } Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]); $workerEnv = [ 'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'], 'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'], 'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array', ]; foreach (['over_quantity', 'same_confirmation', 'complementary_quantities', 'same_delivery_note'] as $scenario) { $fixture = receiptFixture(); $first = receiptPayload($fixture, $scenario === 'complementary_quantities' ? '0.400' : '0.600'); $second = receiptPayload($fixture, '0.600'); if ($scenario === 'same_confirmation') { $second = $first; } elseif ($scenario === 'same_delivery_note') { $second['delivery_note'] = $first['delivery_note']; } $connection = DB::connection(); $connection->beginTransaction(); // Hold the order so both independent requests are demonstrably waiting for locks before release. $connection->table('purchase_orders')->where('id', $fixture['order']->id)->lockForUpdate()->first(); $processes = []; foreach ([$first, $second] as $index => $payload) { $label = $index === 0 ? 'A' : 'B'; $user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver']; $process = new Process([PHP_BINARY, __DIR__.'/receipt-worker.php', $database, (string) $user->id, (string) $fixture['order']->id, $label], base_path(), $workerEnv); $process->setInput(json_encode($payload, JSON_THROW_ON_ERROR)); $process->setTimeout(45); $process->start(); $processes[] = $process; } $statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('procura_receipt_A','procura_receipt_B') AND wait_event_type = 'Lock'"); $deadline = microtime(true) + 20; $waits = 0; do { $statement->execute([$database]); $waits = (int) $statement->fetchColumn(); if ($waits >= 2) { break; } foreach ($processes as $process) { if (! $process->isRunning()) { throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput()); } } usleep(20000); } while (microtime(true) < $deadline); $connection->commit(); if ($waits < 2) { throw new RuntimeException('Não foi demonstrada espera simultânea pelos bloqueios.'); } $results = []; foreach ($processes as $process) { $process->wait(); if (! $process->isSuccessful()) { throw new RuntimeException('Falha do worker: '.$process->getErrorOutput()); } $results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); } $statuses = array_column($results, 'status'); sort($statuses); $expectedStatuses = $scenario === 'same_confirmation' ? [200, 201] : ($scenario === 'complementary_quantities' ? [201, 201] : [201, 422]); $expectedQuantity = $scenario === 'complementary_quantities' ? 1000 : 600; $expectedCount = $scenario === 'complementary_quantities' ? 2 : 1; $progress = app(ReceiptWorkflow::class)->progress($fixture['order']); if ($statuses !== $expectedStatuses || GoodsReceipt::query()->count() !== $expectedCount || StockMovement::query()->count() !== $expectedCount || (int) StockMovement::query()->sum('quantity_milli') !== $expectedQuantity || $progress['lines'][0]['received_milli'] !== $expectedQuantity || count(array_unique(array_column($results, 'backend_pid'))) !== 2) { throw new RuntimeException('Resultado de concorrência inesperado: '.json_encode($results)); } if ($scenario === 'complementary_quantities' && $progress['state'] !== 'complete') { throw new RuntimeException('A soma exacta em PostgreSQL não concluiu a recepção.'); } $report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'receipts' => $expectedCount, 'stock_movements' => $expectedCount, 'quantity_milli' => $expectedQuantity, 'order_state' => $progress['state']]; } $report['passed'] = true; } catch (Throwable $error) { $report['passed'] = false; $report['failure'] = $error->getMessage(); } finally { foreach ($processes as $process) { if ($process->isRunning()) { $process->stop(); } } if (isset($connection) && $connection->transactionLevel() > 0) { $connection->rollBack(); } DB::disconnect('receipt_concurrency'); DB::disconnect('pgsql'); if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) { $admin->exec('DROP DATABASE "'.$database.'"'); $report['test_database_dropped'] = true; } $report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore; } file_put_contents(__DIR__.'/../../docs/recepcao-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL; exit(($report['passed'] ?? false) ? 0 : 1);