*/ private array $managers = []; public function __construct( private readonly TenantRegistry $registry, private readonly Clock $clock, private readonly Configuration $ormConfiguration, ) { } public function getForTenant(TenantId $tenantId): EntityManagerInterface { $key = (string) $tenantId; // Evict idle connections first $this->evictIdleConnections(); // Evict LRU if pool is full and we need a new manager if (!isset($this->managers[$key]) && count($this->managers) >= self::MAX_MANAGERS) { $this->evictLeastRecentlyUsed(); } if (!isset($this->managers[$key])) { $this->managers[$key] = [ 'manager' => $this->createManagerForTenant($tenantId), 'lastUsed' => $this->clock->now(), ]; } else { // Health check before returning cached manager (AC4 requirement) $manager = $this->managers[$key]['manager']; if (!$manager->isOpen() || !$manager->getConnection()->isConnected()) { // Connection is dead, recreate the manager $this->closeAndRemove($key); $this->managers[$key] = [ 'manager' => $this->createManagerForTenant($tenantId), 'lastUsed' => $this->clock->now(), ]; } else { $this->managers[$key]['lastUsed'] = $this->clock->now(); } } return $this->managers[$key]['manager']; } public function getPoolSize(): int { return count($this->managers); } private function evictLeastRecentlyUsed(): void { if (empty($this->managers)) { return; } $oldestKey = null; $oldestTime = null; foreach ($this->managers as $key => $data) { if ($oldestTime === null || $data['lastUsed'] < $oldestTime) { $oldestKey = $key; $oldestTime = $data['lastUsed']; } } if ($oldestKey !== null) { $this->closeAndRemove($oldestKey); } } private function evictIdleConnections(): void { $now = $this->clock->now(); $keysToRemove = []; foreach ($this->managers as $key => $data) { $idleSeconds = $now->getTimestamp() - $data['lastUsed']->getTimestamp(); if ($idleSeconds > self::IDLE_TIMEOUT_SECONDS) { $keysToRemove[] = $key; } } foreach ($keysToRemove as $key) { $this->closeAndRemove($key); } } private function closeAndRemove(string $key): void { if (isset($this->managers[$key])) { $manager = $this->managers[$key]['manager']; if ($manager->isOpen()) { $manager->close(); } unset($this->managers[$key]); } } private function createManagerForTenant(TenantId $tenantId): EntityManagerInterface { $config = $this->registry->getConfig($tenantId); // Parse database URL and create connection parameters $connectionParams = $this->parseConnectionParams($config->databaseUrl); // Health check before creation /** @phpstan-ignore argument.type (Doctrine accepts both URL and explicit params) */ $connection = DriverManager::getConnection($connectionParams); $this->healthCheck($connection); return new EntityManager($connection, $this->ormConfiguration); } /** * @return array */ private function parseConnectionParams(string $databaseUrl): array { // Handle SQLite in-memory specially if (str_starts_with($databaseUrl, 'sqlite:///:memory:')) { return [ 'driver' => 'pdo_sqlite', 'memory' => true, ]; } // For other databases, use URL parameter return ['url' => $databaseUrl]; } private function healthCheck(\Doctrine\DBAL\Connection $connection): void { // Verify the database is accessible by executing a simple query // This implicitly connects and validates the connection $connection->executeQuery('SELECT 1'); } }