CoreData, Synchronisation de contextes Child/Parent
Totalement par hasard, je viens de me rendre compte d'une anomalie lors de la synchronisation d'un contexte Enfant avec son Parent. Cette synchronisation ne m'a jamais posé de souci, et là je suis tombé sur LE cas particulier.
Le besoin
Mon application (iOS pour l'instant) garde dans un repository local une partie d'une base de données accessible par WebService.
Il peut arriver (environ une fois par an, voire moins souvent) que l'utilisateur désire changer cette partie ; lorsqu'il change d'affectation dans sa société par exemple.
Dans ce cas là l'ancienne partie doit être effacée du repository local et remplacée par la nouvelle.
La solution implémentée
Un contexte P (parent) est attaché au repository. Lorsque l'utilisateur commande le changement de partie, je procède de la façon suivante :
1) Suppression des entités de la partie actuelle du contexte P (une bête boucle après un fetch)
2) Création d'un contexte E (Enfant) à partir du contexte P (non sauvegardé car je veux pouvoir revenir à la situation initiale en cas de souci)
3) Création ou mise à jour d'objets dans le contexte E, en programmation concurrente, avec plusieurs requêtes au WebService et plusieurs tâches en parallèle.
4) Lorsque les opérations sont terminées :
a/ si ça s'est terminé sans erreur, le contexte E est sauvegardé, la notification est récupérée pour mettre à jour le contexte P qui est à son tour sauvegardé dans le repository local
b/ en cas d'erreur, le contexte E est détruit, toutes les requêtes et traitements encore en cours sont arrêtés, un rollback est effectué sur le contexte P pour revenir à la situation initiale, et enfin l'erreur est signalée à l'utilisateur
Tout ça fonctionne jusqu'à présent, des dizaines de tests "unitaires" permettent de s'en assurer. Au passage merci les nouveaux outils de test en programmation concurrente de Xcode.
Détails techniques
- les contextes E et P sont bien sûr sollicités sur leurs queues respectives (queue privée pour E, queue principale pour P)
- les données à remplacer sont essentiellement structurées en deux entités liées A <-->> B.
La propagation du delete sur la relation A -->> B est de type Cascade (tout doit disparaà®tre)
La propagation sur la relation B --> A est de type Nullify
- Détail qui me semble important ; lorsque je veux supprimer les objets de la partie actuelle, je fais une requête sur les entités A dont je détruis les objets un à un. Je compte sur le Cascading pour que les entités B soient détruites (et mes tests unitaires permettent de m'assurer que c'est bien le cas)
Problème constaté
Il nous est arrivé cette semaine une situation exceptionnelle. Un utilisateur a changé de partie de base de données pour la même partie. En fait pour être plus précis, la partie a changé de nom et l'utilisateur voulait synchroniser avec ce nouveau nom, ce qui est tout à fait légitime (mais non prévu, donc non testé jusqu'à présent).
à‰videmment, le cas de "remplacement" par la même partie a été testé, et même optimisé ; on ne fait pas de mise à jour tout simplement. Mais nous n'avions pas prévu ce cas de changement de nom.
Problème : dans ce cas là les entités A sont recréées correctement mais pas les entités B ; elles sont toutes effacées.
Ce n'est pas très grave, il suffit grosso-modo de synchroniser une nouvelle fois et tout rentre dans l'ordre (ce n'est pas si simple que ça car l'optimisation "interdit" de remettre à jour la même partie, mais je passe les détails inutiles). Pas très grave, mais énervant car je ne comprends pas pourquoi ce comportement.
Premières explications
Tout se passe comme si la destruction des entités B par Cascading était retardée. Pourtant j'ai bien fait un processPendingChanges avant de créer le contexte E.
Les entités B sont bien recréées dans le contexte E mais elles sont re-détruites au moment de la sauvegarde du contexte P. Et je ne trouve pas ça normal ; il y a sans doute un truc que je n'ai pas compris sur Core Data (et pas qu'un à mon avis).
Pistes
J'entrevois plusieurs pistes pour résoudre mon problème
- undo Manager plutôt que rollback
- copier le repository au lieu de détruire les entités
- ne plus utiliser le delete cascading ; détruire toutes les entités à l'étape 1)
Qu'en pensez-vous ?
Mais sur le fond du fond, j'aimerais bien comprendre ce qui se passe. Pourquoi ma solution actuelle ne fonctionne pas. Des idées ?
Réponses
J'avance un peu dans la résolution de mon problème :
- une destruction explicite des entités B ne change rien, ce n'est donc pas un défaut du Cascading
- si je sauve le contexte P avant de créer le contexte E je n'ai plus l'erreur, mais le souci du coup c'est que je ne reviens pas à la situation initiale en cas d'erreur (étape 4b ci dessus)
Je vais essayer de passer par un UndoManager pour voir ce que cela donne ...
J'avance un peu pour résoudre le problème, mais toujours pas d'explication du phénomène (j'aime bien savoir Pourquoi un truc ne marche pas, trouver un contournement me laisse un goût de pas fini).
En général moi quand je veux faire un mécanisme de merge/synchro, je crée le contexte fils E, puis je supprime toutes les entités de E, je crée les nouvelles entités toujours sur E, et si tout s'est bien passé je sauve E dans P pour valider la transaction.
Sinon petite remarque au passage, tu dis : Attention à ce genre de pratique, car si tu fais tes "création ou mise à jour" de façon concurrente, tu as un risque que pendant qu'un thread 1 teste si l'objet A existe et voyant qu'il n'existe pas va le créer... un thread 2 prenne la main, teste aussi si l'objet A existe... et voit lui aussi qu'il n'existe pas encore (car le thread 1 a été interrompu pile entre le test et la création), donc le thread 2 va lui aussi partir pour créer l'objet... et au final tu vas te retrouver avec 2 instances de l'objet A.
Je ne sais pas ce que tu mets en pratique derrière ton "Création ou mise à jour d'objets", mais selon ce que tu fais, si chaque thread / tâche concurrente opère sur un objet forcément différent ça peut passer (et encore je t'invite à quand même réfléchir à la question pour être sûr que, dans ton cas, il n'y a pas de risque, selon ton algo et tes actions réalisées en parallèle), soit ils peuvent opérer sur le même objet, ou essayer de trouver un objet existant pendant qu'un autre thread est justement en train de le créer ou le supprimer, et là ça peut foutre le boxon...
Les tâches et les requêtes sont concurrentes mais pas les mises à jour qui se font toujours sur la queue privée de E.
Effectivement c'est un peu compliqué car les requêtes et les tâches se terminent dans n'importe quel ordre et donc il faut ruser pour créer les liens entre objets et au final se retrouver avec les bonnes valeurs d'attribut et de relation sur tous les objets.
Je fais ça en deux passes. La première pour identifier le "potentiel" de chaque objet (0, ceux qui ne dépendent de personnes ; 1, ceux qui ne dépendent que des objets 0 ; 2, ceux qui ne dépendent que des 0 et 1, ...), dans cette première passe je ne fais que récupérer les objets bruts du WebService et identifier les potentiels relatifs. À la fin de la première passe j'identifie les potentiels absolus puis je démarre la deuxième passe qui fait les mises à jour définitives. J'ai de la chance, par construction il n'y a pas de cycle dans le modèle d'objets.
Sinon, je vais essayer de faire les delete dans E plutôt que dans P.
En fait au début c'était comme ça car ça paraà®t plus logique d'un point de vue Core Data, mais j'ai changé en mettant au point mon design de contrôleurs à trois niveaux :
- un Manager, propriétaire de P, il a une 'petite' connaissance du modèle d'objets et sait ce qu'il faut détruire pour changer de partie. C'est donc lui qui fait les delete, dans P donc.
- un Coordinator, propriétaire de E, qui ne connait du modèle que le juste nécessaire pour lancer les requêtes, gérer les tâches et la mise à jour de E
- un Communicator qui gère les requêtes, il ne connait rien au modèle, son boulot est juste de transmettre des JSON et des erreurs au Coordinator.
J'ai préféré faire un truc plus cohérent avec mon design car je ne pensais pas que ça pouvait changer quelque chose pour Core Data.
Je vais vérifier ça.
Un autre pattern intéressant pour toi c'est faire ton action en gardant toujours avec toi un NSMutableDictionary.
Quand tu veux traiter l'objet X, tu regardes d'abord dans le dico s'il y a un objet "copié" correspondant à X. Si oui, tu prends cet objet et tu continues l'exploration de ton graphe dans la profondeur. Sinon, tu "copie" ton objet et tu mets un référence dans le dico. Puis tu poursuis ton exploration.
En gros, tu peux faire des méthodes entièrement récursives mais à condition qu'elles prennent en argument un NSMutableDico.
Tu peux regarde ce que j'ai fait là qui met en place ce principe : https://github.com/colasjojo/CBDCoreDataToolKit/tree/master/Classes/Cloning/NSManagedObject%2BCBDClone
@colas2
J'avais tenté un truc comme ça au début, mais je ne m'en suis pas sorti parce que je n'ai pas réussi à tracer correctement s'il fallait ou pas que je propage les modifications.
Mais bon, là j'ai un truc qui marche bien et qui en plus est en O(n) : les batteries des iPhones ne sont pas vidées à chaque mise à jour !
@AliGator
Pas mieux.
- détruire dans E au lieu de détruire dans P ne change rien (ça me rassure pour ma compréhension de Core Data), que je fasse un save de E ou pas, c'est pareil.
- la solution simple avec un undoManager ne fonctionne pas car si je fait un save de P je n'ai plus mon problème, mais du coup l'undoManager par défaut ne fonctionne plus, il faudrait que je me fasse un undoManager spécifique qui agisse même après un save.
Je continue à farfouiller ...
(et merci aussi Git et SourceTree pour ne pas me perdre dans toutes ces modifications de code)