[FIX] migration: un module fautif n'emporte plus tout le lot

« --uninstall » prend une liste virgulée et Odoo annule la transaction
entière au premier échec : soit tout part, soit rien. Mesuré sur une
chaîne 12 → 18 — crm_phone échoue sur une colonne absente de res_users
et fait tomber les 22 autres avec lui, dont huit modules maison sans
code en 13. Ceux-là sont alors montés d'un palier « installed » sans
rien pour les charger, et ne sont partis que trois paliers plus loin,
par accident.

Le lot part toujours d'abord — un démarrage d'Odoo par nom se paierait
cher pour rien. Mais s'il échoue, on reprend un par un : ce qui peut
partir part, et l'on nomme ce qui résiste.

S'ajoute la liste 12 → 13, qui n'existait pas : sans elle l'étape
« Uninstall module » n'avait rien à faire.

--- EN ---

« --uninstall » takes a comma list and Odoo rolls the whole transaction
back on the first failure: all or nothing. Measured on a 12 → 18 chain —
crm_phone fails on a column missing from res_users and drags the other
22 down with it, including eight in-house modules with no code in 13.
Those rode a step up « installed » with nothing to load them, and only
left three steps later, by accident.

The batch still goes first — one Odoo start per name would cost dearly
for nothing. But if it fails, we retry one by one: what can leave
leaves, and we name what resists.

Plus the 12 → 13 list, which did not exist: without it the « Uninstall
module » step had nothing to do.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 05:29:38 -04:00
parent 07df33b3ff
commit e8ab56a0df
4 changed files with 176 additions and 0 deletions

View file

@ -0,0 +1,29 @@
# Modules à retirer AVANT de monter de 12.0 vers 13.0.
#
# Un module par ligne, une justification après « # ».
# Les huit n'ont de code qu'en 12 : `find odoo{13..18}.0 -name <nom>` ne
# rend rien. Sans cette liste, l'étape « Uninstall module » n'a rien à
# faire et ils montent en 13 « installed » sans rien pour les charger.
# Odoo les écarte alors du graphe — « not installable, skipped » — et
# signale des états incohérents ; le ménage ne vient qu'en fin de palier,
# par database_cleanup, qui s'arrête à mi-course : les données partent,
# l'état reste « to remove ». Or « --uninstall » ne cherche que
# « installed » et sort 0 sans rien faire sur « to remove » : le module
# ne se retire alors plus JAMAIS. C'est ainsi que muk_web_theme a
# traversé quatre paliers en étant réputé parti.
#
# Mesuré sur une copie de test_neutralize avec le code de la 12 : les
# huit se retirent en un appel, 139 modules installés → 131, et le diff
# de ir_module_module ne montre que ces huit lignes. Aucune donnée
# d'affaires ne part — les tables sinerkia sont vides, aucune page ne
# pose les snippets maison, et le fuseau America/Montreal survit dans
# ir_default, que la désinstallation ne touche pas.
erplibre_base # code absent dès la 13
erplibre_website_snippets_basic_html # code absent dès la 13
erplibre_website_snippets_cards # code absent dès la 13
erplibre_website_snippets_jitsi # code absent dès la 13
erplibre_website_snippets_structures # code absent dès la 13
erplibre_website_snippets_timelines # code absent dès la 13
partner_quebec_tz # code absent dès la 13 ; le fuseau reste dans ir_default
sinerkia_jitsi_meet # code absent dès la 13 ; ses tables sont vides

View file

@ -1756,6 +1756,10 @@ TRANSLATIONS = {
"fr": "Rien de désinstallé.",
"en": "Nothing uninstalled.",
},
"Retrying alone:": {
"fr": "Reprise seul :",
"en": "Retrying alone:",
},
"The uninstall did not take:": {
"fr": "La désinstallation n'a pas pris :",
"en": "The uninstall did not take:",

View file

@ -3875,6 +3875,17 @@ class TodoUpgrade:
)
lst_left = self.still_installed(database_name, lst_module_to_uninstall)
# UN SEUL nom fautif emporte tout le lot : « --uninstall » prend
# une liste, et Odoo annule la transaction entière au premier
# échec. Mesuré sur une chaîne 12 → 18 : `crm_phone` échoue sur
# une colonne absente de res_users et fait tomber les 22 autres
# avec lui — dont huit modules sans code en 13, qui sont alors
# montés d'un palier « installed » sans rien pour les charger.
#
# On reprend donc un par un : ce qui peut partir part, et l'on
# nomme précisément ce qui résiste.
if lst_left and len(lst_module_to_uninstall) > 1:
lst_left = self.uninstall_one_by_one(database_name, lst_left)
if lst_left is None:
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
print(f"⚠️ {t('Could not verify the uninstall.')}")
@ -3901,6 +3912,27 @@ class TodoUpgrade:
)
self.write_config()
def uninstall_one_by_one(self, database_name, lst_module):
"""Reprendre module par module, et rendre ce qui résiste encore.
Appelée seulement quand le lot a échoué : une commande par nom
coûte un démarrage d'Odoo chacune, ce qu'on ne paie pas pour
rien. Ce qu'on y gagne est qu'un module fautif cesse de protéger
les autres.
"""
reste = []
for nom in lst_module:
print(f"⧖ {t('Retrying alone:')} {nom}")
self.todo_upgrade_execute(
f"./script/addons/uninstall_addons.sh {database_name} {nom}",
single_source_odoo=True,
wait_at_error=False,
)
encore = self.still_installed(database_name, [nom])
if encore is None or encore:
reste.append(nom)
return reste
def install_from_database(
self, lst_module_to_install, database_name, actual_version
):

View file

@ -14,6 +14,7 @@ alors qu'il était « installed » de la 15 à la 18.
import ast
import io
import os
import re
import sys
import unittest
@ -349,5 +350,115 @@ class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase):
self.assertIn("write_config", corps)
class FauxPiloteParNom(FauxPilote):
"""Un faux qui reproduit le VRAI mécanisme du lot.
« --uninstall » prend une liste virgulée, et Odoo annule la
transaction entière au premier échec : soit tout part, soit rien.
C'est cela qu'il faut simuler — un faux où chaque nom part
indépendamment ne montrerait jamais le défaut.
"""
def __init__(self, fautifs=()):
super().__init__()
self.fautifs = set(fautifs)
self.partis = set()
self.tentes = []
def todo_upgrade_execute(self, cmd, **kwargs):
self.commandes.append(cmd)
if kwargs.get("get_output"):
# `still_installed` : ce qu'on demande et qui n'est pas parti.
#
# Les noms se lisent dans la clause `IN (…)` SEULE : une
# capture large ramassait `'uninstalled'` du `state <>` et
# le faux tentait alors de désinstaller un module de ce nom.
dedans = re.search(r"name IN \(([^)]*)\)", cmd)
demandes = re.findall(
r"'([^']+)'", dedans.group(1) if dedans else ""
)
return 0, cmd, [n for n in demandes if n not in self.partis]
if "uninstall_addons.sh" in cmd:
noms = cmd.rsplit(" ", 1)[-1].split(",")
self.tentes.append(list(noms))
if not any(n in self.fautifs for n in noms):
self.partis.update(noms)
return 0, cmd
class TestOneBadNameNoLongerProtectsTheOthers(unittest.TestCase):
"""Un seul module fautif emportait tout le lot.
« --uninstall » prend une liste et Odoo annule la transaction
entière au premier échec. Mesuré sur une chaîne 12 → 18 :
`crm_phone` échoue sur une colonne absente de res_users et fait
tomber les 22 autres — dont huit modules sans code en 13, qui sont
alors montés d'un palier « installed » sans rien pour les charger.
"""
def pilote(self, fautifs, tous):
p = FauxPiloteParNom(fautifs)
p.dct_module_per_version = {12: list(tous)}
return p
def test_the_healthy_ones_leave_even_when_one_resists(self):
tous = ["bon_a", "crm_phone", "bon_b"]
p = self.pilote(["crm_phone"], tous)
p.uninstall_from_database(tous, "db", 12)
# Le lot d'abord, puis chacun seul…
self.assertEqual(tous, p.tentes[0])
self.assertEqual(
sorted(n for lot in p.tentes[1:] for n in lot),
["bon_a", "bon_b", "crm_phone"],
)
# …et la comptabilité ne garde que le fautif.
self.assertEqual(["crm_phone"], p.dct_module_per_version[12])
def test_the_one_that_resists_is_named(self):
tous = ["bon_a", "crm_phone"]
p = self.pilote(["crm_phone"], tous)
p.uninstall_from_database(tous, "db", 12)
trace = " ".join(p.commentaires)
self.assertIn("crm_phone", trace)
self.assertNotIn("bon_a", trace)
def test_a_lone_module_is_not_tried_twice(self):
# La reprise coûte un démarrage d'Odoo par nom : on ne la paie
# pas pour rejouer exactement la même commande.
p = self.pilote(["seul"], ["seul"])
p.uninstall_from_database(["seul"], "db", 12)
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
self.assertEqual(1, len(lancements), lancements)
def test_a_successful_batch_never_retries(self):
tous = ["bon_a", "bon_b"]
p = self.pilote([], tous)
p.uninstall_from_database(tous, "db", 12)
lancements = [c for c in p.commandes if "uninstall_addons.sh" in c]
self.assertEqual(1, len(lancements), lancements)
def test_the_retry_does_not_open_the_error_menu(self):
# Un module qui refuse de partir est une trouvaille, pas une
# panne du pilote : s'arrêter là ferait échouer la migration
# entière pour un module que l'on nomme déjà.
import ast
with io.open(
os.path.join(RACINE, "script", "todo", "todo_upgrade.py"),
encoding="utf-8",
) as handle:
arbre = ast.parse(handle.read())
for noeud in ast.walk(arbre):
if (
isinstance(noeud, ast.FunctionDef)
and noeud.name == "uninstall_one_by_one"
):
corps = ast.dump(ast.Module(body=noeud.body, type_ignores=[]))
self.assertIn("wait_at_error", corps)
self.assertIn("value=False", corps)
return
self.fail("uninstall_one_by_one introuvable")
if __name__ == "__main__":
unittest.main()