Aller au contenu
Sylvain Joffraud l4w
Retour

Doctrine ORM 3 : `EntityNotFoundException` en postRemove

Avec Doctrine ORM 3, une EntityNotFoundException dans un listener postRemove signifie presque toujours qu’on lit un proxy jamais chargé dont la ligne vient d’être supprimée. Dans un SaaS d’abonnements, la revalidation d’un abonnement modifié renvoyait une erreur 500. Le service supprimait l’ancien PDF du contrat d’abonnement, puis un listener postRemove lisait le chemin du fichier pour l’effacer du stockage S3 : Doctrine tentait alors de charger une ligne qui n’existait plus. Le correctif tient en une ligne, initializeObject() avant remove().

Un doublon de contrat, puis un fichier orphelin

Le point de départ est un autre bug. Quand un abonnement déjà validé était modifié puis revalidé, le générateur créait un nouveau PDF du contrat d’abonnement sans retirer l’ancien : deux documents « Contrat » dans la liste du client. Premier correctif, simple :

public function saveSubscriptionAsPdf(Subscription $subscription): Document
{
    $previousContract = $subscription->getContract();

    $file = $this->generatePdfFile($subscription);
    $storedFileData = $this->saveOnStorage($subscription, $file);
    $contract = $this->saveAsDocument($subscription, $storedFileData);

    $this->attachContractDocumentToSubscription($subscription, $contract);
    $this->removePreviousContract($previousContract);

    return $contract;
}

private function removePreviousContract(?Document $previousContract): void
{
    if ($previousContract === null) {
        return;
    }

    $this->entityManager->remove($previousContract);
    $this->entityManager->flush();
}

Le doublon disparaissait de la liste, mais remove() ne retire que la ligne document. Le PDF restait dans le stockage S3 (MinIO), orphelin. L’entité Document n’avait aucun preRemove ni postRemove, et le service de stockage n’exposait même pas de méthode delete(). La suppression manuelle d’un document via l’API laissait déjà des orphelins ; la revalidation en ajoutait un à chaque passage.

Le listener postRemove best-effort

J’ai ajouté FileStorageService::delete() et un entity listener sur postRemove, qui couvre tous les points de suppression d’un Document sans dupliquer de code :

#[AsEntityListener(event: Events::postRemove, entity: Document::class)]
readonly class DocumentListener
{
    public function postRemove(Document $document, PostRemoveEventArgs $args): void
    {
        $filePathName = $document->getFilePathName();
        if ($filePathName === null) {
            return;
        }

        try {
            $this->fileStorageService->delete($filePathName);
        } catch (Throwable $e) {
            $this->logger->error(sprintf('Cannot delete storage file "%s"', $filePathName), ['exception' => $e]);
        }
    }
}

postRemove plutôt que preRemove : le fichier n’est effacé qu’une fois la ligne supprimée en base. Best-effort : un échec côté stockage est loggé mais ne remonte pas, la suppression en base étant déjà faite. Pour les orphelins accumulés avant, une commande app:storage:clean-orphan-documents compare les fichiers du bucket aux file_path_name référencés en base, en dry-run par défaut et avec --force pour supprimer réellement.

Tests unitaires verts des deux côtés. Et pourtant, la revalidation d’un abonnement s’est mise à renvoyer une 500.

Pourquoi Doctrine lève EntityNotFoundException dans un postRemove ?

La relation contract est un OneToOne sans option fetch, donc paresseuse :

#[ORM\OneToOne(targetEntity: Document::class, cascade: ['persist', 'remove'])]
private ?Document $contract;

Un proxy Doctrine est un objet qui remplace l’entité tant qu’on ne l’a pas lue : il ne connaît que son identifiant et exécute le SELECT au premier accès à un champ. La séquence devient :

  1. $subscription->getContract() renvoie un proxy non initialisé.
  2. remove() + flush() : Doctrine exécute le DELETE avec le seul identifiant.
  3. Le listener appelle getFilePathName() : le proxy s’initialise et lance le SELECT.
  4. La ligne n’existe plus : Doctrine ORM 3.6.3 lève l’exception.

Le message est construit par EntityNotFoundException::fromClassNameAndIdentifier() ; avec un identifiant UUID, il prend cette forme :

Entity of type 'App\Entity\Document' for IDs id(0190a3b2-7c4e-7d10-9a1b-3f5e6d7c8b9a) was not found

Un listener postRemove qui lit un champ suppose que l’entité est déjà en mémoire : sur un proxy, c’est une requête vers une ligne qui vient de disparaître.

Aucun des deux commits n’était faux isolément. Le premier supprimait un proxy sans jamais le lire, ce que Doctrine accepte. Le second lisait une entité supprimée, ce qui marche quand l’entité est chargée. Les tests unitaires mockaient l’EntityManager ou construisaient un Document avec new : jamais de proxy. C’est la même limite que dans le moteur de facturation qui oubliait persist() : un mock prouve qu’une méthode est appelée, pas ce que fait Doctrine.

Le correctif : initializeObject() avant remove()

private function removePreviousContract(?Document $previousContract): void
{
    if ($previousContract === null) {
        return;
    }

    // Hydrate le proxy tant que la ligne existe : le postRemove lit getFilePathName().
    $this->entityManager->initializeObject($previousContract);

    $this->entityManager->remove($previousContract);
    $this->entityManager->flush();
}

initializeObject() exécute le SELECT tant que la ligne existe ; sur une entité déjà chargée, il ne relance pas de requête. Le test unitaire vérifie désormais l’appel à initializeObject() avant remove(), et son absence quand il n’y a pas de contrat précédent. Passer la relation en fetch: 'EAGER' aurait aussi marché, au prix d’une jointure à chaque chargement d’abonnement, pour un seul cas d’usage.

Lessons learned

Questions fréquentes

Pourquoi Doctrine lève une <code>EntityNotFoundException</code> sur une entité qui existait ?
Doctrine lève une EntityNotFoundException quand il initialise un proxy et que la requête SELECT par identifiant ne renvoie aucune ligne. Le proxy a été créé quand l’entité existait, mais il n’a jamais été chargé. Si la ligne a été supprimée entre-temps, par exemple par un remove() suivi d’un flush(), le premier getter appelé sur ce proxy déclenche l’exception.
Comment forcer le chargement d’un proxy Doctrine avant de le supprimer ?
Pour forcer le chargement d’un proxy Doctrine, il suffit d’appeler $entityManager->initializeObject($entity) avant remove(). La méthode exécute le SELECT tant que la ligne existe et hydrate tous les champs. Les listeners postRemove peuvent ensuite lire l’objet sans requête supplémentaire. Sur une entité déjà chargée, l’appel ne relance pas de requête.
Faut-il supprimer un fichier lié à une entité en <code>preRemove</code> ou en <code>postRemove</code> ?
Supprimer le fichier lié en postRemove évite d’effacer un fichier dont la ligne resterait en base si la requête DELETE échouait. L’événement postRemove arrive après la suppression en base : l’entité doit donc être déjà chargée en mémoire, car un proxy ne peut plus être initialisé à ce moment-là. La suppression du fichier reste best-effort, avec un log en cas d’échec.
Comment retrouver les fichiers orphelins d’un stockage S3 ou MinIO ?
Les fichiers orphelins d’un stockage S3 ou MinIO se trouvent par différence : lister les objets du bucket, lister les chemins référencés en base, garder ceux qui n’apparaissent que dans le bucket. Une commande Symfony en dry-run par défaut affiche la liste, et une option --force les supprime réellement. La base n’est lue qu’en lecture seule.

Partager cet article :

Article précédent
mlx-lm `mask_prompt` : seul le dernier message est appris
Article suivant
LLM-juge : le time-out qui fait passer un test au vert