feat: Persister les utilisateurs en PostgreSQL avec cache-aside Redis

Les utilisateurs étaient stockés uniquement dans Redis (CacheUserRepository),
ce qui exposait à une perte totale des comptes en cas de restart Redis,
FLUSHDB ou perte du volume Docker. Les tables student_guardians et
teacher_assignments référençaient des user IDs sans FK réelle.

PostgreSQL devient la source de vérité via DoctrineUserRepository (DBAL,
upsert ON CONFLICT). CachedUserRepository décore l'interface existante
avec le pattern cache-aside : lectures Redis d'abord → miss → PostgreSQL
→ populate Redis ; écritures PostgreSQL d'abord → mise à jour Redis.
Si Redis est indisponible, l'application continue via PostgreSQL seul.

Une commande de migration (app:migrate-users-to-postgres) permet de copier
les données Redis existantes vers PostgreSQL de manière idempotente.
This commit is contained in:
2026-02-15 14:39:17 +01:00
parent 76e16db0d8
commit a0e19627a7
15 changed files with 1581 additions and 4 deletions

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function count;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Migrates existing users from Redis cache to PostgreSQL.
*
* This command reads all users stored in Redis (via CacheUserRepository)
* and persists them to PostgreSQL (via DoctrineUserRepository).
* It is idempotent: re-running it will upsert existing records.
*/
#[AsCommand(
name: 'app:migrate-users-to-postgres',
description: 'Migrate existing users from Redis cache to PostgreSQL',
)]
final class MigrateUsersToPostgresCommand extends Command
{
public function __construct(
private readonly UserRepository $source,
private readonly UserRepository $target,
private readonly TenantRegistry $tenantRegistry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Migration des utilisateurs Redis → PostgreSQL');
$tenants = $this->tenantRegistry->getAllConfigs();
$totalMigrated = 0;
foreach ($tenants as $tenant) {
$tenantId = $tenant->tenantId;
$users = $this->source->findAllByTenant($tenantId);
if ($users === []) {
$io->text("Tenant {$tenant->subdomain}: aucun utilisateur");
continue;
}
foreach ($users as $user) {
$this->target->save($user);
++$totalMigrated;
}
$io->text("Tenant {$tenant->subdomain}: " . count($users) . ' utilisateur(s) migré(s)');
}
$io->success("Migration terminée : {$totalMigrated} utilisateur(s) migré(s) vers PostgreSQL");
return Command::SUCCESS;
}
}