From 160b4c92cc0455b93c15e63654b66196e8327fe7 Mon Sep 17 00:00:00 2001 From: Bastien Date: Thu, 13 Aug 2026 08:50:46 +0200 Subject: [PATCH] [Bug] - Corrige l'OOM de l'import INSEE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'import se faisait tuer par l'OOM killer pendant la phase COMMUNES, à la ligne 37 500 du fichier v_commune_2025.csv qui en compte 37 549 — c'est-à-dire au moment précis du flush final. Deux causes distinctes, toutes deux mesurées. D'abord, importData() ne faisait qu'un seul flush, à la fin du fichier, et n'appelait jamais clear(). L'unit of work conservait donc toutes les lignes du fichier courant, mais surtout, clear() n'étant appelé nulle part, les entités des fichiers déjà traités restaient dans l'identity map pour toute la durée d'un run --target=all, soit environ 95 000 entités. Le coût ne vient pas tant du persist() que du flush : persister 37 500 entités ne prend qu'une soixantaine de mégaoctets, ce qui explique que le processus meure exactement à l'entrée du flush terminal et pas avant. On flush et détache désormais toutes les 500 lignes, selon l'idiome déjà en place dans FileParserService::processFile(). Ensuite, la ligne censée économiser de la mémoire dans la commande n'en économisait aucune. setSQLLogger() est l'API DBAL 2 : elle est dépréciée et se contente de renseigner un champ que le middleware de debug ne lit jamais. Le vrai consommateur en mode debug est le BacktraceDebugDataHolder de doctrine-bundle, qui retient chaque requête exécutée pendant toute la vie du processus — environ 290 Mo sur un import de 95 000 INSERT. Ce middleware est câblé à la construction de la connexion et ne peut pas être retiré à chaud ; la commande avertit donc quand elle tourne en mode debug et renvoie vers --no-debug. La dépendance à EntityManagerInterface devenait inutilisée dans la commande, elle est retirée. Mesuré sur --target=all avec une base MySQL neuve, à nombres de lignes identiques (37 548 communes, 42 314 communes historiques, 13 534 mouvements, 95 COMER, 830 outre-mer historiques, 229 pays, 468 pays historiques) : avant 668 Mo 46 s après (debug actif) 310 Mo 44 s après, avec --no-debug 54 Mo 25 s Le pic ne dépend plus de la taille des fichiers, alors qu'il croissait avec elle jusqu'ici. La ligne de progression affiche maintenant la mémoire occupée, pour qu'une régression se voie pendant l'import. Les imports en masse doivent être lancés avec --no-debug. Co-Authored-By: Claude Opus 5 (1M context) --- src/Command/InseeImportCommand.php | 16 +++++++++++---- src/Service/InseeService.php | 31 ++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Command/InseeImportCommand.php b/src/Command/InseeImportCommand.php index c23680d..019e6e6 100644 --- a/src/Command/InseeImportCommand.php +++ b/src/Command/InseeImportCommand.php @@ -4,13 +4,13 @@ use App\Service\InseeService; use DateTime; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\HttpKernel\KernelInterface; #[AsCommand( name: 'app:insee:import', @@ -19,8 +19,8 @@ class InseeImportCommand extends Command { public function __construct( - private readonly EntityManagerInterface $em, private readonly InseeService $inseeService, + private readonly KernelInterface $kernel, ) { parent::__construct(); } @@ -105,8 +105,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln("{$startTime->format('d-m-Y G:i:s')} - Start processing"); $output->writeln("Import ID is {$this->inseeService->getImportId()}"); - // Turn off Doctrine's default SQL logger to save memory - $this->em->getConnection()->getConfiguration()->setSQLLogger(); + // In debug mode Doctrine keeps every executed query in memory to feed the profiler. This + // import runs ~95 000 INSERTs, which the collector alone turns into some 290 MB that is + // never released. setSQLLogger(), used here before, is the DBAL 2 API: it is deprecated + // and the debug middleware never reads it, so it saved nothing. + if ($this->kernel->isDebug()) { + $io->warning( + 'Debug mode is on: Doctrine will keep every executed query in memory and the ' + . 'import will need roughly 290 MB more. Run it with --no-debug.' + ); + } // If purge is requested, execute it before importing if ($purge) { diff --git a/src/Service/InseeService.php b/src/Service/InseeService.php index f62bff6..27ee1be 100644 --- a/src/Service/InseeService.php +++ b/src/Service/InseeService.php @@ -14,6 +14,15 @@ class InseeService extends ImporterService { + /** + * Rows kept in the unit of work before it is flushed and detached. + * + * The INSEE files run to tens of thousands of rows (37k communes, 42k historical ones), and + * a run with --target=all chains seven of them. Holding them all until the end of the file + * made the import peak around 670 MB and get killed by the OOM killer. + */ + private const int BATCH_SIZE = 500; + private bool $verbose = false; private int $nbSuccessCommune = 0; private int $nbSuccessCommune1943 = 0; @@ -62,9 +71,13 @@ public function importData(string $filePath, string $type, string $separator = ' $this->processLine($data, $type); ++$lineCounter; - if (0 === $lineCounter % 500 && !$this->verbose) { - // Output a progress message every 500 lines - $this->output->writeln("Processed $lineCounter lines..."); + if (0 === $lineCounter % self::BATCH_SIZE) { + $this->flushBatch(); + + if (!$this->verbose) { + $memory = round(memory_get_usage(true) / 1048576); + $this->output->writeln("Processed $lineCounter lines... ({$memory} MB)"); + } } } @@ -75,11 +88,21 @@ public function importData(string $filePath, string $type, string $separator = ' return false; } - $this->em->flush(); + $this->flushBatch(); return true; } + /** + * Writes the pending rows and detaches them, so that the unit of work does not keep growing + * for the whole file. Nothing here reads back a persisted entity, so detaching them is safe. + */ + private function flushBatch(): void + { + $this->em->flush(); + $this->em->clear(); + } + /** * Process a single CSV row based on the dataset type. */