Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
160 lines
4.7 KiB
PHP
160 lines
4.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Shared\Infrastructure\Tenant;
|
|
|
|
use App\Shared\Domain\Clock;
|
|
|
|
use function count;
|
|
|
|
use DateTimeImmutable;
|
|
use Doctrine\DBAL\DriverManager;
|
|
use Doctrine\ORM\Configuration;
|
|
use Doctrine\ORM\EntityManager;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
final class TenantEntityManagerFactory
|
|
{
|
|
private const MAX_MANAGERS = 50;
|
|
private const IDLE_TIMEOUT_SECONDS = 300; // 5 minutes
|
|
|
|
/** @var array<string, array{manager: EntityManagerInterface, lastUsed: DateTimeImmutable}> */
|
|
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<string, mixed>
|
|
*/
|
|
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');
|
|
}
|
|
}
|