samedi 19 février 2011

NonUniqueObjectException, cascade et evict

Notre équipe d'architectes (Yannick et moi-même) a volé à la rescousse par un développeur qui ne savait plus à quel Saint se vouer.

Son code, à base d'Hibernate et de Spring, lançait une org.hibernate.NonUniqueObjectException et il n'en comprenait pas l'origine et pouvait encore moins s'en débarrasser.

Je l’avoue, dans les standards de développement mis en place (et que certains appellent à tort "architecture"), c’est la première fois que je rencontre ce cas.

L’API d’Hibernate décrit l'exception comme suit:
This exception is thrown when an operation would break session-scoped identity. This occurs if the user tries to associate two different instances of the same Java class with a particular identifier, in the scope of a single Session.

Reproduction simple

L'erreur est facile à reproduire. Utilisons pour l'exemple une entité Dog, simpliste:
@Entity
public class Dog {
     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     //Setters, getters, equals, hashcode...   
}

Dans la base de données, il y au moins un entrée d’id 1 et de name "Bill". Voici comment reproduire l’erreur:
public class NonUniqueChien {
     public static void main(String[] args) {
          SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
          Session session = sf.openSession();
          Transaction tx = session.beginTransaction();

          Dog oldDog = (Dog) session.get(Dog.class, 1);

          Dog dog = new Dog();
          dog.setName("Totor");
          dog.setId(1);

          session.saveOrUpdate(dog);

          tx.commit();
          session.close();
          sf.close();
     }
}

L’exécution de ce script provoque la org.hibernate.NonUniqueObjectException.

Explications:
  1. A la ligne 7, le code va rechercher l’entité Dog correspondant à l’id 1 (ce brave Bill). La session contient donc une entité Dog d’id 1.
  2. De 9 à 11, le code construit ensuite un objet Dog transient, avec un autre nom, mais le même id.
  3. A la ligne 13, il appelle saveOrUpdate. Dans la mesure où il y a un id, déclaré comme étant auto-généré, Hibernate part du principe que c’est un update. Par sécurité, le framework vérifie si l’objet existe déjà en session, ce qui est le cas (il s'agit de Bill). Ce n’est donc pas la même référence. Du point de vue d’Hibernate, il y a un risque: deux objets, représentant la même entité, peuvent potentiellement contenir des champs différents (ce qui est le cas ici). Quelle entité persister? Le chien qui s’appelle “Bill” ou celui qui s’appelle “Totor”? Ne pouvant décider, Hibernate lance la NonUniqueObjectException.

A noter que le problème se pose aussi avec "update" (bien sûr), mais pas avec "save" (Hibernate considère qu’il s’agit d’une nouvelle entité et génère un index à la place de celui fourni), ni avec merge évidemment.

Le problème est apparemment simple, mais détecter où il se produit est plus difficile. Le développeur, débutant en Spring et en Hibernate, a perdu le contrôle sur son application. Le modèle est complexe, avec des références vers de nombreuses autres entités dans des relations ToMany ou ToOne, toujours bidirectionnelles et du cascading CascadeType.ALL sur toutes les relations. C’est un cauchemar. Les méthodes s’enchaînent, passent d’un service ou d’un DAO à l’autre, sautant du transactionnel au non transactionnel...

Reproduction du problème complexe


Après un long moment de debugging, le noeud du problème est enfin trouvé.

Le développeur, parce qu'il ne maîtrise pas les transactions, le dirty checking et le lazy-loading, a fait beaucoup d'erreurs. Pour tenter de résoudre une des difficultés rencontrées, il a utilisé à plusieurs reprises des "evict" sur la session.

Une petite parenthèse s'impose ici: que ne nous a-t-il pas appelé à l'aide plus tôt, dés qu'il a eu des soucis! Au lieu de ça, il s'est enfoncé dans du bricolage fait du bricolage, pour obtenir au final un code spaghetti, non maintenable, d'une grande fragilité... et qui ne fonctionne pas. Fin de la parenthèse.

Voici comment on peut reproduire le problème tel qu'il l'a, mais d'une manière hautement simplifiée...

Une deuxième entité est nécessaire: le maître du chien. Présentation donc de Master:

@Entity
public class Master {

     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     @OneToOne(cascade=javax.persistence.CascadeType.ALL)
     private Dog dog;

     //setters getters, equals, hashcode    
}


En DB, une ligne est créée dans chacune des tables. Nous avons maintenant notre Dog d'id=1 (Bill) et un Master d'id=2 (Boule), fier maître de Bill grâce à sa foreignkey vers Dog valant 1.

Le code suivant va provoquer l’erreur:

public class NonUniqueObjectTest {
    public static void main(String[] args) {
        SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
        Session session = sf.openSession();
        Transaction tx = session.beginTransaction();
       
        Master boule = (Master) session.get(Master.class,2);
        session.evict(boule.getDog());
       
        Dog bill = (Dog) session.get(Dog.class, 1);
       
        tx.commit();
        session.close();
        sf.close();
    }
}


Explications
La cause du problème est due à une combinaison du "evict" et du cascading. Des deux, seul le "evict" est réellement erroné. Si le cascading était supprimé, il n'y aurait plus d'erreur, mais la logique du code resterait douteuse.
  1. A la ligne 7, l'entité Master (Boule) est récupérée. Elle contient une référence vers Dog (Bill).
  2. A la ligne 8, l'entité Dog (Bill) est détachée de la session avec "evict".
  3. A la ligne 10, l'entité Dog (Bill) est récupérée à nouveau. Il y a alors deux objets Dog (Bill): un est référencé par la propriété dog de Master (Bill), l'autre par la variable bill.
  4. A la ligne 12, la transaction est commitée. La session est flushée. La cascading amène Hibernate à vérifier le statut de l'objet référencé par dog. Voyant qu'il n'est pas attaché, il détecte qu'il est détaché (et non transient) et vérifie que la session ne contient pas déjà cette entité. Or, comme nous l'avons rechargée à la ligne 10, elle la contient. Hibernate, ne pouvant décider quel objet est le bon, lance une exception. L'ironie dans le cas présent est que les deux objets sont rigoureusement identiques...

Solution

Malheureusement pour le développeur, il n'y a pas de solution miracle. Même si dans le code qui précède, on pourrait résoudre le problème de différentes manières (retirer le "evict", retirer le cascading, limiter le cascading pour ne pas considérer les updates...), ce n'est pas aussi simple dans la réalité.

Le code développé est fragile, les effets de bord sont nombreux. S'il y a un evict, c'est parce qu'il en a eu besoin à ce moment-là. Même chose pour le cascading.

Pour lui, une seule solution, tout recommencer et mieux maîtriser la succession des opérations.

Pour les autres, une règle de base: éviter le evict. C'est rarement utile.

Aucun commentaire:

Enregistrer un commentaire