[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
|
|
"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge.
|
|
|
|
|
|
|
|
|
|
|
|
Les huit purges ne sont pas indépendantes : purger un modèle libère les
|
|
|
|
|
|
colonnes qui le référençaient, purger une table libère les données qui la
|
|
|
|
|
|
visaient. Une seule passe ne suffit jamais, et l'ordre compte.
|
|
|
|
|
|
|
|
|
|
|
|
Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module
|
|
|
|
|
|
refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque
|
|
|
|
|
|
entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas
|
|
|
|
|
|
réparé, la suivante le peut, une fois les voisines parties.
|
|
|
|
|
|
|
|
|
|
|
|
Ces tests exécutent le script réellement poussé dans le shell, sur un `env`
|
|
|
|
|
|
simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et
|
|
|
|
|
|
l'arrêt de la boucle sans lancer Odoo sur une vraie base.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
|
|
|
|
|
|
|
|
|
|
|
import database_cleanup as cleanup # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeLine:
|
|
|
|
|
|
def __init__(self, name, fails=0, journal=None):
|
|
|
|
|
|
self.name = name
|
|
|
|
|
|
self.id = abs(hash(name)) % 10000
|
|
|
|
|
|
self.fails = fails # nombre de refus avant de céder
|
|
|
|
|
|
self.journal = journal if journal is not None else []
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
self.purged = False
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
def purge(self):
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
# Comme le vrai : une ligne déjà purgée ne l'est pas deux fois.
|
|
|
|
|
|
# `purge()` d'OCA filtre sur `not x.purged`, et sans cela le repli
|
|
|
|
|
|
# ligne à ligne recompterait ce que le lot avait déjà fait.
|
|
|
|
|
|
if self.purged:
|
|
|
|
|
|
return True
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
self.journal.append(("purge", self.name))
|
|
|
|
|
|
if self.fails > 0:
|
|
|
|
|
|
self.fails -= 1
|
|
|
|
|
|
raise RuntimeError(f"refus sur {self.name}")
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
self.purged = True
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeRecordset(list):
|
|
|
|
|
|
"""Un `purge_line_ids` qui se purge EN LOT, comme le vrai.
|
|
|
|
|
|
|
|
|
|
|
|
C'est TOUT l'enjeu du correctif : `purge()` d'un module appelle
|
|
|
|
|
|
button_immediate_uninstall(), qui recharge le registre entier. Un appel
|
|
|
|
|
|
par ligne en faisait un rechargement par module — mesuré, dix secondes
|
|
|
|
|
|
chacun. Un seul appel sur le lot, c'est un seul rechargement.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, lines, journal=None):
|
|
|
|
|
|
super().__init__(lines)
|
|
|
|
|
|
self.journal = journal if journal is not None else []
|
|
|
|
|
|
|
|
|
|
|
|
def purge(self):
|
|
|
|
|
|
self.journal.append(("purge_batch", len(self)))
|
|
|
|
|
|
for line in self:
|
|
|
|
|
|
line.purge()
|
|
|
|
|
|
return True
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeWizard:
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
def __init__(self, lines, journal=None):
|
|
|
|
|
|
self.purge_line_ids = FakeRecordset(lines, journal)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeModel:
|
|
|
|
|
|
def __init__(self, lines, raise_on_create=None):
|
|
|
|
|
|
self._lines = lines
|
|
|
|
|
|
self._raise = raise_on_create
|
|
|
|
|
|
|
|
|
|
|
|
def create(self, values):
|
|
|
|
|
|
if self._raise:
|
|
|
|
|
|
raise RuntimeError(self._raise)
|
|
|
|
|
|
# Les lignes déjà purgées ne reviennent pas : find() les recalcule.
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
reste = [ln for ln in self._lines if ln.fails >= 0 and not ln.purged]
|
|
|
|
|
|
journal = reste[0].journal if reste else None
|
|
|
|
|
|
return FakeWizard(reste, journal)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeCursor:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
"""Volontairement SANS `savepoint` : c'est l'invariant du correctif.
|
|
|
|
|
|
|
|
|
|
|
|
Le module OCA valide de lui-même — `purge_modules.find()` purge (ligne
|
|
|
|
|
|
91) et `purge_columns.purge()` appelle `cr.commit()` (ligne 57). Un
|
|
|
|
|
|
COMMIT détruit tous les points de reprise. En reprendre un ici ferait
|
|
|
|
|
|
revivre le défaut sans que rien ne le dise ; l'absence de la méthode
|
|
|
|
|
|
le fait échouer tout de suite.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
def __init__(self, journal):
|
|
|
|
|
|
self.journal = journal
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def commit(self):
|
|
|
|
|
|
self.journal.append(("commit", None))
|
|
|
|
|
|
|
|
|
|
|
|
def rollback(self):
|
|
|
|
|
|
self.journal.append(("rollback", None))
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
class AbortingCursor(FakeCursor):
|
|
|
|
|
|
"""Ce que PostgreSQL fait VRAIMENT après une erreur.
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
La transaction reste avortée et refuse tout ordre jusqu'au rollback.
|
|
|
|
|
|
C'est ce qui changeait une panne sur « modules » en sept catégories
|
|
|
|
|
|
mortes, toutes sur « current transaction is aborted ».
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, journal):
|
|
|
|
|
|
super().__init__(journal)
|
|
|
|
|
|
self.aborted = False
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
def commit(self):
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
if self.aborted:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"current transaction is aborted, commands ignored"
|
|
|
|
|
|
" until end of transaction block"
|
|
|
|
|
|
)
|
|
|
|
|
|
super().commit()
|
|
|
|
|
|
|
|
|
|
|
|
def rollback(self):
|
|
|
|
|
|
super().rollback()
|
|
|
|
|
|
self.aborted = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CommittingLine(FakeLine):
|
|
|
|
|
|
"""Une purge qui valide toute seule, comme `purge_columns` le fait.
|
|
|
|
|
|
|
|
|
|
|
|
Le COMMIT emporte le point de reprise ; l'ordre suivant meurt sur
|
|
|
|
|
|
« savepoint ... does not exist » et laisse la transaction avortée.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, name, cursor, journal=None):
|
|
|
|
|
|
super().__init__(name, journal=journal)
|
|
|
|
|
|
self.cursor = cursor
|
|
|
|
|
|
|
|
|
|
|
|
def purge(self):
|
|
|
|
|
|
self.journal.append(("purge", self.name))
|
|
|
|
|
|
self.cursor.commit()
|
|
|
|
|
|
self.cursor.aborted = True
|
|
|
|
|
|
raise RuntimeError('savepoint "10eb69719a9211f1" does not exist')
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeEnv(dict):
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def __init__(self, mapping, journal, cursor=None):
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
super().__init__(mapping)
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
self.cr = cursor if cursor is not None else FakeCursor(journal)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
class UserError(Exception):
|
|
|
|
|
|
"""Celle que le script poussé importera : on fournit odoo.exceptions.
|
|
|
|
|
|
|
|
|
|
|
|
Refaire une classe de son côté ne servirait à rien — « except » compare
|
|
|
|
|
|
des identités, pas des noms, et le test passerait à côté.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_fake_odoo_exceptions(case):
|
|
|
|
|
|
"""Rendre `from odoo.exceptions import UserError` possible ici."""
|
|
|
|
|
|
import types
|
|
|
|
|
|
|
|
|
|
|
|
odoo = sys.modules.get("odoo") or types.ModuleType("odoo")
|
|
|
|
|
|
exceptions = types.ModuleType("odoo.exceptions")
|
|
|
|
|
|
exceptions.UserError = UserError
|
|
|
|
|
|
avant_odoo = sys.modules.get("odoo")
|
|
|
|
|
|
avant_exc = sys.modules.get("odoo.exceptions")
|
|
|
|
|
|
sys.modules["odoo"] = odoo
|
|
|
|
|
|
sys.modules["odoo.exceptions"] = exceptions
|
|
|
|
|
|
|
|
|
|
|
|
def remettre():
|
|
|
|
|
|
for nom, valeur in (
|
|
|
|
|
|
("odoo", avant_odoo),
|
|
|
|
|
|
("odoo.exceptions", avant_exc),
|
|
|
|
|
|
):
|
|
|
|
|
|
if valeur is None:
|
|
|
|
|
|
sys.modules.pop(nom, None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sys.modules[nom] = valeur
|
|
|
|
|
|
|
|
|
|
|
|
case.addCleanup(remettre)
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def run_script(models, max_round=10, dry_run=False, cursor=None):
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
"""Exécuter le script réellement poussé, et rendre (rapport, journal)."""
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
journal = cursor.journal if cursor is not None else []
|
|
|
|
|
|
env = FakeEnv(models, journal, cursor=cursor)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
namespace = {"env": env}
|
|
|
|
|
|
exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102
|
|
|
|
|
|
# Le script imprime le rapport entre deux sentinelles ; ici on le relit
|
|
|
|
|
|
# dans son espace de noms, ce qui teste la MÊME structure.
|
|
|
|
|
|
return json.loads(json.dumps(namespace["report"])), journal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheOrder(unittest.TestCase):
|
|
|
|
|
|
def test_the_requested_order_is_kept(self):
|
|
|
|
|
|
# Purger un modèle libère des colonnes : l'inverse ne marcherait pas.
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[kind for kind, _model in cleanup.ORDER],
|
|
|
|
|
|
[
|
|
|
|
|
|
"models",
|
|
|
|
|
|
"modules",
|
|
|
|
|
|
"columns",
|
|
|
|
|
|
"tables",
|
|
|
|
|
|
"data",
|
|
|
|
|
|
"menus",
|
|
|
|
|
|
"indexes",
|
|
|
|
|
|
"properties",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_indexes_come_after_the_purges(self):
|
|
|
|
|
|
kinds = [kind for kind, _ in cleanup.ORDER]
|
|
|
|
|
|
self.assertGreater(kinds.index("indexes"), kinds.index("tables"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_pass_visits_the_kinds_in_that_order(self):
|
|
|
|
|
|
models = {
|
|
|
|
|
|
model: FakeModel([FakeLine(f"{kind}-1")])
|
|
|
|
|
|
for kind, model in cleanup.ORDER
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _journal = run_script(models, max_round=1)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[entry["kind"] for entry in report["rounds"][0]],
|
|
|
|
|
|
[kind for kind, _ in cleanup.ORDER],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOneEntryCannotSinkThePass(unittest.TestCase):
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
def test_a_healthy_category_is_purged_in_ONE_call(self):
|
|
|
|
|
|
# LE point du correctif. `purge()` d'un module appelle
|
|
|
|
|
|
# button_immediate_uninstall(), qui recharge le registre ENTIER —
|
|
|
|
|
|
# 5984 modules à relire. En purgeant ligne par ligne j'en faisais
|
|
|
|
|
|
# un rechargement PAR MODULE : mesuré, dix secondes chacun,
|
|
|
|
|
|
# dix-sept minutes pour neuf modules, sans rien afficher.
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
FakeLine("a", journal=journal),
|
|
|
|
|
|
FakeLine("b", journal=journal),
|
|
|
|
|
|
FakeLine("c", journal=journal),
|
|
|
|
|
|
]
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1)
|
|
|
|
|
|
# `journal` est celui des LIGNES : c'est là qu'atterrit la purge.
|
|
|
|
|
|
self.assertEqual(journal.count(("purge_batch", 3)), 1)
|
|
|
|
|
|
self.assertEqual(report["rounds"][0][0]["purged"], 3)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_healthy_case_commits_once_for_the_batch(self):
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
journal = []
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
FakeLine("a", journal=journal),
|
|
|
|
|
|
FakeLine("b", journal=journal),
|
|
|
|
|
|
]
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
|
|
|
|
_report, got = run_script(models, max_round=1)
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
# Une validation après la création — `find()` peut avoir purgé de
|
|
|
|
|
|
# lui-même — et une pour le lot. Pas une par ligne.
|
|
|
|
|
|
self.assertEqual(got.count(("commit", None)), 2)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def test_a_refusal_gives_up_only_its_own(self):
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
journal = []
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
FakeLine("ok1", journal=journal),
|
|
|
|
|
|
FakeLine("bad", fails=99, journal=journal),
|
|
|
|
|
|
FakeLine("ok2", journal=journal),
|
|
|
|
|
|
]
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
|
|
|
|
report, got = run_script(models, max_round=1)
|
|
|
|
|
|
entry = report["rounds"][0][0]
|
|
|
|
|
|
self.assertEqual(entry["purged"], 2)
|
|
|
|
|
|
self.assertEqual([name for name, _msg in entry["errors"]], ["bad"])
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
# Le lot a été tenté, a échoué, et SEULEMENT alors on isole. Le
|
|
|
|
|
|
# coût du ligne-à-ligne n'est payé que là où il sert.
|
|
|
|
|
|
self.assertEqual(journal.count(("purge_batch", 3)), 1)
|
|
|
|
|
|
self.assertGreaterEqual(got.count(("rollback", None)), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_isolation_only_happens_after_a_refusal(self):
|
|
|
|
|
|
# Une catégorie saine ne doit JAMAIS passer par le repli : c'est
|
|
|
|
|
|
# lui qui coûtait dix secondes par module.
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: FakeModel(
|
|
|
|
|
|
[
|
|
|
|
|
|
FakeLine("a", journal=journal),
|
|
|
|
|
|
FakeLine("b", journal=journal),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
_report, got = run_script(models, max_round=1)
|
|
|
|
|
|
self.assertEqual(got.count(("rollback", None)), 0)
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
|
|
|
|
|
|
def test_the_script_never_takes_a_savepoint(self):
|
|
|
|
|
|
# L'invariant du correctif, dit une fois pour toutes : le module OCA
|
|
|
|
|
|
# valide de lui-même, et un COMMIT détruit le point de reprise
|
|
|
|
|
|
# qu'on aurait pris. Le reprendre serait revenir au défaut.
|
|
|
|
|
|
self.assertNotIn("env.cr.savepoint", cleanup.build_script(1, False))
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
|
|
|
|
|
def test_a_wizard_that_cannot_even_be_created_is_recorded(self):
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1)
|
|
|
|
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
|
|
|
|
self.assertIn("boom", report["failed"][0][2])
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
class TestTheReportSurvivesAnything(unittest.TestCase):
|
|
|
|
|
|
"""Sans rapport, on ne sait même pas si la base a été touchée.
|
|
|
|
|
|
|
|
|
|
|
|
Vécu : `create({})` échouait, l'erreur était notée mais la transaction
|
|
|
|
|
|
restait AVORTÉE. La lecture de nom suivante mourait dessus, hors de tout
|
|
|
|
|
|
garde, et le script entier s'arrêtait — aucun rapport, juste une trace.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def test_reading_the_names_is_inside_the_guard(self):
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
# C'est la lecture des noms qui déclenche la requête, pas la
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
# création : la laisser hors du `try` était le défaut — elle mourait
|
|
|
|
|
|
# sur une transaction déjà avortée, sans rien pour la rattraper.
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
source = cleanup.build_script(1, False)
|
|
|
|
|
|
creation = source.index("wizard = env[model].create({})")
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
garde = source.rindex("try:", 0, creation)
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
noms = source.index("line.name or str(line.id)")
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
rattrapage = source.index("except UserError:", noms)
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
self.assertLess(garde, creation)
|
|
|
|
|
|
self.assertLess(creation, noms)
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
self.assertLess(noms, rattrapage)
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
def test_a_failure_on_create_does_not_kill_the_run(self):
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom"),
|
|
|
|
|
|
cleanup.ORDER[2][1]: FakeModel([FakeLine("colonne")]),
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1)
|
|
|
|
|
|
# La catégorie suivante a bien travaillé malgré l'échec de la
|
|
|
|
|
|
# première.
|
|
|
|
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
|
|
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
|
|
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unexpected_failure_still_yields_a_report(self):
|
|
|
|
|
|
class Explosive(dict):
|
|
|
|
|
|
def __init__(self, journal):
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self.cr = FakeCursor(journal)
|
|
|
|
|
|
|
|
|
|
|
|
def __contains__(self, key):
|
|
|
|
|
|
raise RuntimeError("registre en miettes")
|
|
|
|
|
|
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
namespace = {"env": Explosive(journal)}
|
|
|
|
|
|
exec(cleanup.build_script(1, False), namespace) # noqa: S102
|
|
|
|
|
|
report = _json.loads(_json.dumps(namespace["report"]))
|
|
|
|
|
|
self.assertEqual(report["failed"][0][:2], ["*", "fatal"])
|
|
|
|
|
|
self.assertIn("miettes", report["failed"][0][2])
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
class TestTheSilenceThatLookedLikeAHang(unittest.TestCase):
|
|
|
|
|
|
"""Dix-sept minutes sans une ligne, et l'on croit à une boucle infinie.
|
|
|
|
|
|
|
|
|
|
|
|
Vécu, sur test_neutralize_upgrade_16 : l'outil affichait « ⧖ Nettoyage
|
|
|
|
|
|
de … » puis PLUS RIEN. Le processus travaillait — zéro verrou en
|
|
|
|
|
|
attente, des requêtes qui changeaient à chaque instantané — mais un
|
|
|
|
|
|
travail qui avance et un blocage se ressemblent trait pour trait quand
|
|
|
|
|
|
aucun des deux ne parle. On interrompt alors une réparation à moitié
|
|
|
|
|
|
faite, ce qui est le pire des deux mondes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_pushed_script_announces_what_it_does(self):
|
|
|
|
|
|
source = cleanup.build_script(1, False)
|
|
|
|
|
|
self.assertIn(cleanup.STEP, source)
|
|
|
|
|
|
self.assertIn("flush=True", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_announces_each_pass(self):
|
|
|
|
|
|
source = cleanup.build_script(1, False)
|
|
|
|
|
|
self.assertIn('step("pass"', source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_parent_relays_each_line_AS_IT_ARRIVES(self):
|
|
|
|
|
|
"""Le test comportemental, et non plus un mot cherché dans le code.
|
|
|
|
|
|
|
|
|
|
|
|
`capture_output=True` ne rend la main qu'à la fin : c'était la cause
|
|
|
|
|
|
du silence, pas la lenteur elle-même. On vérifie donc qu'une ligne
|
|
|
|
|
|
de progression ressort AVANT que le processus n'ait fini de parler.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
lignes = [
|
|
|
|
|
|
f"{cleanup.STEP} modules 1/3 vieux_module\n",
|
|
|
|
|
|
"un journal Odoo sans rapport\n",
|
|
|
|
|
|
f"{cleanup.STEP} columns 2/3 res_partner.x\n",
|
|
|
|
|
|
f"{cleanup.START}\n",
|
|
|
|
|
|
'{"rounds": [], "missing": [], "failed": []}\n',
|
|
|
|
|
|
f"{cleanup.END}\n",
|
|
|
|
|
|
]
|
|
|
|
|
|
vu = []
|
|
|
|
|
|
|
|
|
|
|
|
class FauxProcessus:
|
|
|
|
|
|
def __init__(self, lst):
|
|
|
|
|
|
self.stdin = io.StringIO()
|
|
|
|
|
|
self.pid = -1
|
|
|
|
|
|
self._lst = lst
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def stdout(self):
|
|
|
|
|
|
# Un générateur : chaque ligne n'existe qu'au moment où on
|
|
|
|
|
|
# la lit, comme un vrai tube. Rendre la liste entière
|
|
|
|
|
|
# laisserait passer une lecture en bloc.
|
|
|
|
|
|
for rang, ligne in enumerate(self._lst):
|
|
|
|
|
|
vu.append(("lu", rang))
|
|
|
|
|
|
yield ligne
|
|
|
|
|
|
|
|
|
|
|
|
def wait(self, timeout=None):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
def poll(self):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
original = cleanup.subprocess.Popen
|
|
|
|
|
|
cleanup.subprocess.Popen = lambda *a, **kw: FauxProcessus(lignes)
|
|
|
|
|
|
self.addCleanup(setattr, cleanup.subprocess, "Popen", original)
|
|
|
|
|
|
report = cleanup.run_shell(
|
|
|
|
|
|
"db",
|
|
|
|
|
|
"./config.conf",
|
|
|
|
|
|
"script",
|
|
|
|
|
|
echo=lambda ligne: vu.append(("relayé", ligne)),
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(report["rounds"], [])
|
|
|
|
|
|
relayes = [x for x in vu if x[0] == "relayé"]
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[x[1] for x in relayes],
|
|
|
|
|
|
["modules 1/3 vieux_module", "columns 2/3 res_partner.x"],
|
|
|
|
|
|
)
|
|
|
|
|
|
# ET au fil de l'eau : le relais suit IMMÉDIATEMENT la lecture de
|
|
|
|
|
|
# sa ligne. « quelque part avant la fin » ne suffirait pas — une
|
|
|
|
|
|
# lecture en bloc suivie d'une boucle de relais passerait aussi.
|
|
|
|
|
|
self.assertEqual(vu[vu.index(("lu", 0)) + 1], relayes[0])
|
|
|
|
|
|
self.assertEqual(vu[vu.index(("lu", 2)) + 1], relayes[1])
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_relay_shows_the_elapsed_time(self):
|
|
|
|
|
|
# Ce qui distingue « ça avance lentement » de « ça ne bouge plus ».
|
|
|
|
|
|
import io
|
|
|
|
|
|
import time as _time
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
|
from contextlib import redirect_stdout
|
[FIX] database_cleanup: purge the batch, and say what you are doing
Seventeen minutes with no output looks exactly like an infinite loop. It
was not one: measured on the running process, zero lock waits and queries
changing every sample. It was working, silently, one module every thirty
seconds.
Both halves were mine. Purging line by line — added so one refusal could
not sink a category — calls button_immediate_uninstall() per line, and
that reloads the WHOLE registry: 5984 modules re-read each time. The
batch is now purged in one call, exactly as the OCA wizard intends, and
the per-line isolation only costs when a refusal actually happens.
And the tool captured its child's output, so nothing showed before the
end. It now relays each step as it arrives, with elapsed seconds, and a
timer kills the process group when the deadline passes.
--- FR ---
[FIX] database_cleanup : purger le lot, et dire ce qu'on fait
Dix-sept minutes sans une ligne ressemblent trait pour trait à une boucle
infinie. Ce n'en était pas une : mesuré sur le processus vivant, zéro
verrou en attente et des requêtes qui changeaient à chaque instantané. Il
travaillait, en silence, à raison d'un module toutes les trente secondes.
Les deux moitiés étaient de mon fait. Purger ligne par ligne — ajouté
pour qu'un refus n'emporte pas la catégorie — appelle
button_immediate_uninstall() par ligne, ce qui recharge TOUT le registre :
5984 modules relus à chaque fois. Le lot est désormais purgé en un seul
appel, comme le module OCA le prévoit, et l'isolement ne coûte que
lorsqu'un refus survient vraiment.
Et l'outil capturait la sortie de son enfant : rien avant la fin. Il
relaie maintenant chaque étape, temps écoulé compris.
Assisted-by: Claude Opus 5
2026-08-17 23:19:04 -04:00
|
|
|
|
|
|
|
|
|
|
echo = cleanup.make_echo(_time.monotonic() - 42)
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
|
echo("modules 2/9 stock_deposit")
|
|
|
|
|
|
self.assertIn("42", out.getvalue())
|
|
|
|
|
|
self.assertIn("stock_deposit", out.getvalue())
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_slowness_is_announced_up_front(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
|
|
|
|
self.assertIn("reloads the whole registry", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_timeout_cannot_be_starved_by_silence(self):
|
|
|
|
|
|
# Un délai vérifié DANS la boucle de lecture ne se déclencherait
|
|
|
|
|
|
# jamais : cette boucle bloque tant qu'aucune ligne n'arrive, et
|
|
|
|
|
|
# c'est exactement le cas qu'il faut couvrir.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.run_shell)
|
|
|
|
|
|
self.assertIn("threading.Timer", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_stopping_kills_the_whole_group(self):
|
|
|
|
|
|
# « ./odoo_bin.sh » est un script bash : un terminate() sur lui tue
|
|
|
|
|
|
# le script et laisse odoo-bin vivant, sur la base, avec ses
|
|
|
|
|
|
# verrous. La leçon a déjà été payée en serveurs orphelins.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.kill_group)
|
|
|
|
|
|
self.assertIn("killpg", source)
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"start_new_session", inspect.getsource(cleanup.run_shell)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_timeout_is_reported_as_such(self):
|
|
|
|
|
|
# Sans cela, un arrêt à l'expiration se lisait « le nettoyage n'a
|
|
|
|
|
|
# produit aucun rapport » — un diagnostic faux.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.run_shell)
|
|
|
|
|
|
self.assertIn("was still running after", source)
|
|
|
|
|
|
self.assertLess(
|
|
|
|
|
|
source.index("was still running after"),
|
|
|
|
|
|
source.index("produced no report"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
class TestTheCascadeThatKilledEverything(unittest.TestCase):
|
|
|
|
|
|
"""Vécu, sur test_neutralize_upgrade_13 : sept catégories mortes d'une.
|
|
|
|
|
|
|
|
|
|
|
|
passe 1 : 0 purgés
|
|
|
|
|
|
⚠️ 0 purgés ; 7 n'ont pas pu l'être :
|
|
|
|
|
|
- [modules] - : savepoint "10eb6971..." does not exist
|
|
|
|
|
|
- [columns] - : current transaction is aborted, commands ignored
|
|
|
|
|
|
... et ainsi de suite jusqu'à la dernière.
|
|
|
|
|
|
|
|
|
|
|
|
Une seule panne, six victimes. La cause n'était pas dans OCA mais chez
|
|
|
|
|
|
nous : on n'a jamais remis la transaction d'aplomb après l'échec.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def build(self):
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
cursor = AbortingCursor(journal)
|
|
|
|
|
|
coupable = CommittingLine("colonne_morte", cursor, journal=journal)
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[1][1]: FakeModel([coupable]),
|
|
|
|
|
|
cleanup.ORDER[2][1]: FakeModel(
|
|
|
|
|
|
[FakeLine("suivante", journal=journal)]
|
|
|
|
|
|
),
|
|
|
|
|
|
cleanup.ORDER[3][1]: FakeModel(
|
|
|
|
|
|
[FakeLine("encore", journal=journal)]
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
return run_script(models, max_round=1, cursor=cursor)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_categories_after_it_still_work(self):
|
|
|
|
|
|
report, _got = self.build()
|
|
|
|
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
|
|
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
|
|
|
|
self.assertEqual(purged.get("tables"), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_nobody_else_reports_an_aborted_transaction(self):
|
|
|
|
|
|
# C'est la SIGNATURE de la cascade : six lignes qui ne disent rien
|
|
|
|
|
|
# de leur propre catégorie, seulement qu'une autre a échoué avant.
|
|
|
|
|
|
report, _got = self.build()
|
|
|
|
|
|
contamines = [
|
|
|
|
|
|
entry
|
|
|
|
|
|
for round_ in report["rounds"]
|
|
|
|
|
|
for entry in round_
|
|
|
|
|
|
for _name, message in entry["errors"]
|
|
|
|
|
|
if "transaction is aborted" in message
|
|
|
|
|
|
]
|
|
|
|
|
|
self.assertEqual(contamines, [])
|
|
|
|
|
|
self.assertEqual(report["failed"], [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_real_failure_is_still_reported(self):
|
|
|
|
|
|
# Rattraper ne veut pas dire taire : la colonne n'a PAS été purgée.
|
|
|
|
|
|
report, _got = self.build()
|
|
|
|
|
|
entry = [e for e in report["rounds"][0] if e["kind"] == "modules"][0]
|
|
|
|
|
|
self.assertEqual(entry["purged"], 0)
|
|
|
|
|
|
self.assertIn("savepoint", entry["errors"][0][1])
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_transaction_is_put_back_on_its_feet(self):
|
|
|
|
|
|
_report, got = self.build()
|
|
|
|
|
|
self.assertIn(("rollback", None), got)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestADeadCreateDoesNotContaminate(unittest.TestCase):
|
|
|
|
|
|
def test_the_next_category_is_not_dragged_down(self):
|
|
|
|
|
|
# `create({})` qui échoue laisse la transaction avortée : sans
|
|
|
|
|
|
# rollback, la catégorie suivante mourait sur l'erreur d'une autre.
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
cursor = AbortingCursor(journal)
|
|
|
|
|
|
|
|
|
|
|
|
class MortAuDepart(FakeModel):
|
|
|
|
|
|
def create(self, values):
|
|
|
|
|
|
cursor.aborted = True
|
|
|
|
|
|
raise RuntimeError("registre indisponible")
|
|
|
|
|
|
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: MortAuDepart([]),
|
|
|
|
|
|
cleanup.ORDER[2][1]: FakeModel(
|
|
|
|
|
|
[FakeLine("colonne", journal=journal)]
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1, cursor=cursor)
|
|
|
|
|
|
purged = {e["kind"]: e["purged"] for e in report["rounds"][0]}
|
|
|
|
|
|
self.assertEqual(purged.get("columns"), 1)
|
|
|
|
|
|
self.assertEqual(report["failed"][0][0], "models")
|
|
|
|
|
|
self.assertIn("registre", report["failed"][0][2])
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
class TestGoingRoundAgain(unittest.TestCase):
|
|
|
|
|
|
def test_what_one_pass_refused_the_next_may_take(self):
|
|
|
|
|
|
# LE point, et la raison même de boucler : une entrée refuse TANT QUE
|
|
|
|
|
|
# sa voisine est là. Une ligne qui guérirait toute seule n'existe pas
|
|
|
|
|
|
# — ce qui existe, c'est une dépendance qui tombe.
|
|
|
|
|
|
etat = {"table_partie": False}
|
|
|
|
|
|
|
|
|
|
|
|
class Dependante(FakeLine):
|
|
|
|
|
|
def purge(self):
|
|
|
|
|
|
if not etat["table_partie"]:
|
|
|
|
|
|
raise RuntimeError("la table la retient encore")
|
|
|
|
|
|
self.fails = -1
|
|
|
|
|
|
|
|
|
|
|
|
class Liberatrice(FakeLine):
|
|
|
|
|
|
def purge(self):
|
|
|
|
|
|
etat["table_partie"] = True
|
|
|
|
|
|
self.fails = -1
|
|
|
|
|
|
|
|
|
|
|
|
modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1]
|
|
|
|
|
|
models = {
|
|
|
|
|
|
modele_col: FakeModel([Dependante("colonne_liee")]),
|
|
|
|
|
|
modele_tab: FakeModel([Liberatrice("vieille_table")]),
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _got = run_script(models, max_round=5)
|
|
|
|
|
|
# Passe 1 : la colonne refuse, la table part → du progrès, on continue.
|
|
|
|
|
|
self.assertEqual(report["rounds"][0][0]["purged"], 0)
|
|
|
|
|
|
self.assertEqual(report["rounds"][0][1]["purged"], 1)
|
|
|
|
|
|
# Passe 2 : la colonne cède, sa dépendance étant partie.
|
|
|
|
|
|
self.assertEqual(report["rounds"][1][0]["purged"], 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_pass_that_repairs_nothing_ends_it(self):
|
|
|
|
|
|
# Rien n'a changé dans la base : une passe de plus rendrait le même
|
|
|
|
|
|
# refus. Boucler serait des minutes brûlées pour rien.
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _got = run_script(models, max_round=8)
|
|
|
|
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
self.assertEqual(len(report["rounds"][0][0]["errors"]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_loop_stops_when_a_pass_repairs_nothing(self):
|
|
|
|
|
|
# Ce qui résistait au tour d'avant résistera encore : boucler
|
|
|
|
|
|
# jusqu'à max_round brûlerait des minutes pour rien.
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)])
|
|
|
|
|
|
}
|
|
|
|
|
|
report, _got = run_script(models, max_round=8)
|
|
|
|
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_nothing_to_do_is_one_pass(self):
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel([])}
|
|
|
|
|
|
report, _got = run_script(models, max_round=8)
|
|
|
|
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatIsAbsentIsSkipped(unittest.TestCase):
|
|
|
|
|
|
def test_a_missing_wizard_is_noted_not_fatal(self):
|
|
|
|
|
|
# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property
|
|
|
|
|
|
# par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur
|
|
|
|
|
|
# une version où il n'a plus lieu d'être.
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel([])}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1)
|
|
|
|
|
|
self.assertIn("properties", report["missing"])
|
|
|
|
|
|
self.assertIn("indexes", report["missing"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_kind_is_noted_once(self):
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])}
|
|
|
|
|
|
report, _got = run_script(models, max_round=5)
|
|
|
|
|
|
self.assertEqual(report["missing"].count("properties"), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheDryRun(unittest.TestCase):
|
|
|
|
|
|
def test_it_purges_nothing(self):
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
lines = [FakeLine("a", journal=journal)]
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel(lines)}
|
|
|
|
|
|
report, got = run_script(models, max_round=5, dry_run=True)
|
|
|
|
|
|
self.assertEqual(journal, [])
|
|
|
|
|
|
self.assertNotIn(("commit", None), got)
|
|
|
|
|
|
self.assertEqual(report["rounds"][0][0]["would"], ["a"])
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def test_it_undoes_what_merely_looking_caused(self):
|
|
|
|
|
|
# `purge_modules.find()` PURGE de lui-même : une simulation écrivait
|
|
|
|
|
|
# donc pour de bon, ce qui lui retire tout son sens. On défait ce
|
|
|
|
|
|
# que la lecture a provoqué — possible ici, justement parce qu'on
|
|
|
|
|
|
# n'a validé aucune entrée.
|
|
|
|
|
|
journal = []
|
|
|
|
|
|
models = {
|
|
|
|
|
|
cleanup.ORDER[0][1]: FakeModel([FakeLine("a", journal=journal)])
|
|
|
|
|
|
}
|
|
|
|
|
|
_report, got = run_script(models, max_round=5, dry_run=True)
|
|
|
|
|
|
self.assertIn(("rollback", None), got)
|
|
|
|
|
|
self.assertNotIn(("commit", None), got)
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
def test_it_does_a_single_pass(self):
|
|
|
|
|
|
# Rien ne change, donc rien ne se libère : boucler serait du vent.
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])}
|
|
|
|
|
|
report, _got = run_script(models, max_round=9, dry_run=True)
|
|
|
|
|
|
self.assertEqual(len(report["rounds"]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheReport(unittest.TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
|
)
|
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
|
|
def test_leftovers_are_a_warning_not_a_failure(self):
|
|
|
|
|
|
report = {
|
|
|
|
|
|
"rounds": [
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "models",
|
|
|
|
|
|
"purged": 3,
|
|
|
|
|
|
"errors": [["x", "held"]],
|
|
|
|
|
|
"would": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
],
|
|
|
|
|
|
"missing": [],
|
|
|
|
|
|
"failed": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
text = cleanup.render(report, "db")
|
|
|
|
|
|
self.assertIn("⚠️", text)
|
|
|
|
|
|
self.assertIn("not a failure", text)
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_clean_says_so(self):
|
|
|
|
|
|
report = {
|
|
|
|
|
|
"rounds": [
|
|
|
|
|
|
[{"kind": "models", "purged": 3, "errors": [], "would": []}]
|
|
|
|
|
|
],
|
|
|
|
|
|
"missing": [],
|
|
|
|
|
|
"failed": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
self.assertIn("✅", cleanup.render(report, "db"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_dry_run_is_not_read_as_refusals(self):
|
|
|
|
|
|
# 586 candidats ne sont pas 586 refus : la première version les
|
|
|
|
|
|
# affichait comme des échecs.
|
|
|
|
|
|
report = {
|
|
|
|
|
|
"rounds": [
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
"kind": "columns",
|
|
|
|
|
|
"purged": 0,
|
|
|
|
|
|
"errors": [],
|
|
|
|
|
|
"would": ["a", "b"],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
],
|
|
|
|
|
|
"missing": [],
|
|
|
|
|
|
"failed": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
text = cleanup.render(report, "db")
|
|
|
|
|
|
self.assertIn("would be purged", text)
|
|
|
|
|
|
self.assertNotIn("⚠️", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
|
|
|
|
|
|
"""Interroger les pages sur une base encombrée fait chercher à côté."""
|
|
|
|
|
|
|
|
|
|
|
|
def source(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_runs_before_both_smoke_tests(self):
|
|
|
|
|
|
source = self.source()
|
|
|
|
|
|
self.assertEqual(source.count("prompt_database_cleanup"), 2)
|
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
|
nettoyage = source.index("prompt_database_cleanup")
|
|
|
|
|
|
mesure = source.index("prompt_smoke_public_url")
|
|
|
|
|
|
self.assertLess(nettoyage, mesure)
|
|
|
|
|
|
source = source[mesure + 1 :]
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_gets_a_real_terminal(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_database_cleanup)
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
self.assertIn("run_tool(", source)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
self.assertNotIn("todo_upgrade_execute", source)
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
|
capture = inspect.getsource(TodoUpgrade.run_captured)
|
|
|
|
|
|
self.assertIn("run_captured", inspect.getsource(TodoUpgrade.run_tool))
|
|
|
|
|
|
# Un pseudo-terminal, jamais un tube : derrière un tube
|
|
|
|
|
|
# `can_ask()` rend False et l'outil cesse de poser sa question.
|
|
|
|
|
|
self.assertIn("pty.spawn", capture)
|
|
|
|
|
|
self.assertNotIn("subprocess.PIPE", capture)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
|
def test_saying_no_cleans_nothing(self):
|
|
|
|
|
|
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
import contextlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
|
)
|
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
upgrade.dct_progression = {}
|
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
|
upgrade.write_config = lambda: None
|
|
|
|
|
|
lst_cmd = []
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
|
upgrade.ask_gate = lambda prompt, default="": "n" or default
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
|
|
|
|
upgrade.prompt_database_cleanup("db")
|
|
|
|
|
|
self.assertEqual(lst_cmd, [])
|
|
|
|
|
|
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
|
def test_the_default_cleans(self):
|
|
|
|
|
|
# Entrée nettoie : le nettoyage précède les tests de fumée, et une
|
|
|
|
|
|
# base encombrée de tables mortes fait échouer des pages pour une
|
|
|
|
|
|
# raison qui n'a rien à voir avec la migration.
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
upgrade.dct_progression = {}
|
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
|
upgrade.write_config = lambda: None
|
|
|
|
|
|
lst_cmd = []
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
|
upgrade.ask_gate = lambda prompt, default="": "" or default
|
|
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
|
|
|
|
upgrade.prompt_database_cleanup("db")
|
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
|
self.assertIn("database_cleanup.py", lst_cmd[0])
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
def test_yes_runs_it_on_that_database(self):
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
upgrade.dct_progression = {}
|
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
|
upgrade.write_config = lambda: None
|
|
|
|
|
|
lst_cmd = []
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every
default was EMPTY: it took nothing. Worse, half the prompts of a
migration are asked by separate processes — the theme uninstaller, the
stale-SCSS detector, the smoke test. They knew nothing of auto-run and
waited forever for a keystroke that never came.
The countdown now lives in one file and travels through the ENVIRONMENT,
the only channel a fork shares. Enter takes the default everywhere, not
only under auto-run: a prompt that prints (Y/n) and does otherwise is
worse than no prompt. Every text was rewritten to say what Enter does,
and every default kept an explicit way out.
Two defaults now write. Both are tenable only because the backup runs
first, and a test locks that ORDER rather than trusting a promise.
--- FR ---
[ADD] migration : faire d'Entrée la réponse qu'on donne toujours
L'auto-exécution promettait « le défaut après cinq secondes », mais tous
les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des
invites d'une migration sont posées par d'autres processus — le
désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils
ignoraient l'auto-exécution et attendaient sans fin une frappe.
Le compte à rebours tient désormais dans un seul fichier et voyage par
l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut
partout, pas seulement en auto : une invite qui affiche (Y/n) et fait
l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée,
et chaque défaut garde une issue explicite.
Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe
d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse.
Assisted-by: Claude Opus 5
2026-08-17 20:47:17 -04:00
|
|
|
|
upgrade.ask_gate = lambda prompt, default="": "y" or default
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
upgrade.prompt_database_cleanup("db_upgrade_18")
|
|
|
|
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
|
|
|
|
self.assertIn("database_cleanup.py", lst_cmd[0])
|
|
|
|
|
|
self.assertIn("-d db_upgrade_18", lst_cmd[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
class TestNothingToPurgeIsNotAFailure(unittest.TestCase):
|
|
|
|
|
|
"""Le module signale le VIDE par une exception.
|
|
|
|
|
|
|
|
|
|
|
|
`raise UserError("No orphaned models found")` : le compter comme un
|
|
|
|
|
|
échec faisait passer une base saine pour une base cassée, avec quatre
|
|
|
|
|
|
avertissements sur cinq catégories.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_pushed_script_separates_it(self):
|
|
|
|
|
|
source = cleanup.build_script(1, False)
|
|
|
|
|
|
self.assertIn("except UserError:", source)
|
|
|
|
|
|
vide = source.index("except UserError:")
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
echec = source.index('note(label, "-", exc)')
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
self.assertLess(vide, echec, "l'ordre des except décide")
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_is_reported_as_zero_not_as_an_error(self):
|
|
|
|
|
|
install_fake_odoo_exceptions(self)
|
|
|
|
|
|
|
|
|
|
|
|
class Empty(FakeModel):
|
|
|
|
|
|
def create(self, values):
|
|
|
|
|
|
raise UserError("No orphaned models found")
|
|
|
|
|
|
|
|
|
|
|
|
models = {cleanup.ORDER[0][1]: Empty([])}
|
|
|
|
|
|
report, _got = run_script(models, max_round=1)
|
|
|
|
|
|
entry = report["rounds"][0][0]
|
|
|
|
|
|
self.assertEqual(entry["purged"], 0)
|
|
|
|
|
|
self.assertEqual(entry["errors"], [])
|
|
|
|
|
|
self.assertEqual(report["failed"], [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheModuleIsInstalledFirst(unittest.TestCase):
|
|
|
|
|
|
"""Sans le module, aucun assistant n'existe et l'outil dit « rien ».
|
|
|
|
|
|
|
|
|
|
|
|
Ce silence se lit comme un succès. La migration l'installait à l'étape
|
|
|
|
|
|
3, donc APRÈS le nettoyage de l'étape 2 : l'ordre rendait l'outil
|
|
|
|
|
|
inutile au premier passage.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_checks_the_state_before_cleaning(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
|
|
|
|
self.assertIn("module_state(", source)
|
|
|
|
|
|
self.assertLess(
|
|
|
|
|
|
source.index("module_state("), source.index("run_shell(")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_installs_when_absent(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
|
|
|
|
self.assertIn('state != "installed"', source)
|
|
|
|
|
|
self.assertIn("install_module(", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_install_stops_there(self):
|
|
|
|
|
|
# Nettoyer sans le module rendrait « rien à faire » sur une base qui
|
|
|
|
|
|
# en avait besoin.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(cleanup.main)
|
|
|
|
|
|
install = source.index("install_module(")
|
|
|
|
|
|
self.assertIn("return 2", source[install : install + 400])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheMigrationNoLongerAsksTwice(unittest.TestCase):
|
|
|
|
|
|
def test_the_manual_cleanup_prompt_is_gone(self):
|
|
|
|
|
|
# Le faire à la main après l'avoir fait automatiquement.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
|
self.assertNotIn("Did you finish to clean database", source)
|
|
|
|
|
|
self.assertNotIn("Go to Settings / Technical / Cleanup", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_late_install_is_gone_too(self):
|
|
|
|
|
|
# Elle arrivait à l'étape 3, après l'usage de l'étape 2 : l'outil
|
|
|
|
|
|
# pose désormais le module lui-même, au bon moment.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
|
"install_addons.sh {database_name} database_cleanup", source
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
|
|
|
|
|
|
"""Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_mismatch_is_refused(self):
|
|
|
|
|
|
original_checkout = cleanup.checkout_version
|
|
|
|
|
|
original_db = cleanup.database_version
|
|
|
|
|
|
cleanup.checkout_version = lambda: "14.0"
|
|
|
|
|
|
cleanup.database_version = lambda database: "17.0"
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
|
|
|
|
)
|
|
|
|
|
|
self.addCleanup(setattr, cleanup, "database_version", original_db)
|
|
|
|
|
|
message = cleanup.require_matching_version("db")
|
|
|
|
|
|
self.assertIsNotNone(message)
|
|
|
|
|
|
self.assertIn("14.0", message)
|
|
|
|
|
|
self.assertIn("17.0", message)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_match_passes(self):
|
|
|
|
|
|
original_checkout = cleanup.checkout_version
|
|
|
|
|
|
original_db = cleanup.database_version
|
|
|
|
|
|
cleanup.checkout_version = lambda: "17.0"
|
|
|
|
|
|
cleanup.database_version = lambda database: "17.0"
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
|
|
|
|
)
|
|
|
|
|
|
self.addCleanup(setattr, cleanup, "database_version", original_db)
|
|
|
|
|
|
self.assertIsNone(cleanup.require_matching_version("db"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_not_knowing_does_not_block(self):
|
|
|
|
|
|
# Refuser sur une supposition empêcherait de nettoyer là où c'est
|
|
|
|
|
|
# possible.
|
|
|
|
|
|
original_checkout = cleanup.checkout_version
|
|
|
|
|
|
cleanup.checkout_version = lambda: None
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, cleanup, "checkout_version", original_checkout
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIsNone(cleanup.require_matching_version("db"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
unittest.main()
|