'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]); // Hydrated models retain their connection name. Use one alias for the entire // fixture/workflow transaction so FK checks never wait on our other connection. DB::disconnect('receipt_concurrency'); DB::setDefaultConnection('pgsql'); DB::statement("SET lock_timeout = '25s'"); $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_issue', 'same_issue', 'same_transfer', 'opposing_transfers', 'same_adjustment', 'stale_adjustment', 'spare_part_issue'] as $scenario) { $fixture = receiptFixture(); $equipment = null; if ($scenario === 'spare_part_issue') { $level = CriticalityLevel::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'CRIT-PG', 'name' => 'Critério fictício', 'criteria' => 'Somente para concorrência isolada', 'is_critical' => true, 'created_at' => now()]); $equipment = Equipment::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'department_id' => $fixture['department']->id, 'criticality_level_id' => $level->id, 'code' => 'EQ-PG', 'description' => 'Equipamento fictício', 'area' => 'Linha fictícia', 'status' => 'stopped']); $part = SparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'stock_item_id' => $fixture['stock']->id, 'criticality_level_id' => $level->id, 'minimum_milli' => 1000]); EquipmentSparePart::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'equipment_id' => $equipment->id, 'spare_part_id' => $part->id, 'created_at' => now()]); } app(ReceiptWorkflow::class)->confirm($fixture['receiver'], $fixture['order'], receiptPayload($fixture, '1.000')); $destination = Warehouse::query()->create(['tenant_id' => $fixture['tenant']->id, 'created_by' => $fixture['receiver']->id, 'code' => 'DEST', 'name' => 'Destino fictício']); $first = ['kind' => 'issue', 'reference' => 'SAI-A', 'idempotency_key' => (string) Str::uuid(), 'warehouse_id' => $fixture['warehouse']->id, 'stock_item_id' => $fixture['stock']->id, 'responsible_id' => $fixture['issuer']->id, 'destination' => 'Produção fictícia', 'reason' => 'Ensaio isolado de concorrência', 'quantity' => '0.600']; if ($equipment) { $first['equipment_id'] = $equipment->id; } $second = [...$first, 'reference' => 'SAI-B', 'idempotency_key' => (string) Str::uuid()]; if ($scenario === 'same_issue') { $second = $first; } elseif (in_array($scenario, ['same_transfer', 'opposing_transfers'])) { $first['kind'] = 'transfer'; $first['destination_warehouse_id'] = $destination->id; $second = $first; if ($scenario === 'opposing_transfers') { $second['warehouse_id'] = $destination->id; $second['destination_warehouse_id'] = $fixture['warehouse']->id; $second['reference'] = 'TR-B'; $second['idempotency_key'] = (string) Str::uuid(); $second['quantity'] = '0.100'; } } elseif (in_array($scenario, ['same_adjustment', 'stale_adjustment'])) { $countPayload = [...$first, 'kind' => 'count', 'quantity' => '0.800', 'ledger_version' => (int) StockMovement::query()->max('id')]; // Use the HTTP validation/canonicalisation path for the count as well. $request = Request::create('/stock/operacoes', 'POST', $countPayload, server: ['HTTP_ACCEPT' => 'application/json']); $request->setUserResolver(fn () => $fixture['receiver']); $response = app(StockOperationController::class)->store($request, app(StockWorkflow::class)); $countId = json_decode($response->getContent(), true)['id']; $first = [...$first, 'kind' => 'adjustment', 'reference' => 'AJ-A', 'count_id' => $countId, 'idempotency_key' => (string) Str::uuid()]; $second = $first; if ($scenario === 'stale_adjustment') { $issue = [...$countPayload, 'kind' => 'issue', 'reference' => 'SAI-BEFORE', 'idempotency_key' => (string) Str::uuid(), 'quantity' => '0.100']; $request = Request::create('/stock/operacoes', 'POST', $issue, server: ['HTTP_ACCEPT' => 'application/json']); $request->setUserResolver(fn () => $fixture['receiver']); app(StockOperationController::class)->store($request, app(StockWorkflow::class)); } } $connection = DB::connection(); $connection->beginTransaction(); $connection->table('tenants')->where('id', $fixture['tenant']->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__.'/stock-worker.php', $database, (string) $user->id, '0', $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_stock_A','procura_stock_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('Sobreposição de bloqueios não demonstrada.'); } $results = []; foreach ($processes as $process) { $process->wait(); if (! $process->isSuccessful()) { throw new RuntimeException($process->getErrorOutput()); }$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); } $statuses = array_column($results, 'status'); sort($statuses); $expected = match ($scenario) { 'over_issue', 'spare_part_issue' => [201, 422],'stale_adjustment' => [422, 422],'opposing_transfers' => null,default => [200, 201] }; if (($expected !== null && $statuses !== $expected) || count(array_unique(array_column($results, 'backend_pid'))) !== 2) { throw new RuntimeException('Respostas inesperadas: '.json_encode($results)); } $source = app(StockWorkflow::class)->balance($fixture['stock']->id, $fixture['warehouse']->id)['quantity']; $target = app(StockWorkflow::class)->balance($fixture['stock']->id, $destination->id)['quantity']; $expectedSource = match ($scenario) { 'same_adjustment' => 800,'stale_adjustment' => 900,default => 400 }; if ($scenario === 'opposing_transfers') { if (! in_array($statuses, [[201, 201], [201, 422]], true) || $source + $target !== 1000 || $source < 0 || $target < 0) { throw new RuntimeException('Transferências opostas inválidas.'); } } elseif ($source !== $expectedSource || $target !== ($scenario === 'same_transfer' ? 600 : 0)) { throw new RuntimeException('Saldos inesperados.'); } foreach (StockOperation::query()->where('kind', 'transfer')->get() as $transfer) { $moves = StockMovement::query()->where('stock_operation_id', $transfer->id); if ($moves->count() !== 2 || (int) $moves->sum('quantity_milli') !== 0) { throw new RuntimeException('Transferência incompleta.'); } } $report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'responses' => $results, 'source_milli' => $source, 'destination_milli' => $target]; } $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/stock-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);