erplibre/script/odoo/migration/fix_duplicate_index.py

288 lines
9.4 KiB
Python
Raw Normal View History

[ADD] migration: retirer les index qu'Odoo 17 a créés en double `make_index_name` rend `{table}__{colonne}_index` depuis la 17 — deux soulignés. Le nouvel index est créé, l'ancien reste. Mesuré sur une chaîne 12 → 18 : 1 paire en 12, 3 en 16, 370 en 17, 365 en 18. Inerte à la lecture, coûteux à l'écriture — deux arbres B entretenus à chaque INSERT — et c'est le seul défaut de la famille qui empire tout seul. L'outil s'abstient plus qu'il ne supprime : contrainte, clé primaire, index partiel ou calculé, méthode ou classe d'opérateurs différentes, convention non reconnaissable. Sur la base réelle, 367 sûrs sur 376. Éprouvé sur une copie : 364 retirés, 10 Mo libérés, les 6301 contraintes identiques au nom près, Odoo charge sans une ligne de journal, et un « -u all » complet n'en recrée aucun. --- EN --- `make_index_name` returns `{table}__{column}_index` since 17 — two underscores. The new index is created, the old one stays. Measured on a 12 → 18 chain: 1 pair in 12, 3 in 16, 370 in 17, 365 in 18. Inert on read, costly on write — two B-trees maintained per INSERT — and the only defect of this family that worsens on its own. The tool abstains more than it drops: constraint, primary key, partial or computed index, differing access method or operator class, unrecognisable convention. On the real database, 367 safe out of 376. Proven on a copy: 364 dropped, 10 MB freed, the 6301 constraints identical name for name, Odoo loads without a single log line, and a full « -u all » recreates none of them. Assisted-by: Claude Opus 5
2026-08-24 04:02:27 -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)
"""Les index que la 17 a créés en double et n'a jamais retirés.
Odoo 17 a changé sa convention de nommage : `make_index_name` rend
désormais `{table}__{colonne}_index` — deux soulignés — là où les
versions d'avant écrivaient `{table}_{colonne}_index`. Le nouvel index
est créé, l'ancien reste. Mesuré sur une chaîne 12 → 18 :
test_neutralize (12) 1 paire
_16 3
_17 370 ← la bascule
_18 365
Inerte à la lecture, coûteux à l'écriture : chaque INSERT et chaque
UPDATE sur ces tables entretient deux arbres B identiques. Ici 9,6 Mo et
une base vide ; sur une production le coût croît avec les lignes. C'est
le seul défaut de cette famille qui empire tout seul.
Ce que l'outil REFUSE de toucher, et pourquoi :
adossé à une contrainte PostgreSQL le recréerait, ou la contrainte
tomberait avec lui.
clé primaire même raison, en pire.
index partiel ou calculé `indpred` / `indexprs` : deux index sur les
mêmes colonnes n'y font pas le même travail.
méthode différente un gin et un btree sur la même colonne ne se
remplacent pas.
convention ambiguë aucun des deux noms — ou les deux — suit la
convention de la cible : on ne devine pas
lequel Odoo recréera.
Lecture seule par défaut. `--apply` supprime, puis RELIT pour vérifier.
"""
import argparse
import json
import os
import subprocess
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Les paires strictement identiques : même table, mêmes colonnes, même
# unicité, même méthode d'accès, mêmes classes d'opérateurs. Sans un seul
# de ces critères on comparerait des index qui ne font pas le même travail.
DETECTION = """
WITH idx AS (
SELECT i.indexrelid,
i.indrelid,
i.indkey::text AS cols,
i.indclass::text AS classes,
i.indisunique AS unique_,
am.amname AS methode,
c.relname AS nom,
t.relname AS tbl,
pg_relation_size(i.indexrelid) AS taille,
EXISTS (SELECT 1 FROM pg_constraint k
WHERE k.conindid = i.indexrelid) AS contrainte
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_am am ON am.oid = c.relam
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND i.indpred IS NULL
AND i.indexprs IS NULL
AND NOT i.indisprimary
AND i.indisvalid
)
SELECT coalesce(json_agg(json_build_object(
'table', a.tbl,
'a', a.nom, 'a_size', a.taille, 'a_ctr', a.contrainte,
'b', b.nom, 'b_size', b.taille, 'b_ctr', b.contrainte
)), '[]')
FROM idx a
JOIN idx b
ON a.indrelid = b.indrelid
AND a.cols = b.cols
AND a.classes = b.classes
AND a.unique_ = b.unique_
AND a.methode = b.methode
AND a.indexrelid < b.indexrelid
"""
def run_psql(database, sql, read_only=True):
"""Interroger la base. None si elle ne répond pas.
Lecture seule garantie par le SERVEUR : une suppression qui se trompe
de base doit être refusée par PostgreSQL, pas par une relecture.
"""
env = os.environ.copy()
if read_only:
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
return done.stdout
def modern_name(table, nom):
"""Ce nom suit-il la convention de la cible ?
`make_index_name` d'Odoo 18 rend `{table}__{colonne}_index`. Un nom
trop long est tronqué et suffixé d'un condensat ; celui-là ne se
reconnaît pas, et l'on préfère s'abstenir que deviner.
"""
return nom.startswith(f"{table}__")
def classify(paire):
"""(verdict, à garder, à supprimer).
« safe » exige que l'un des deux noms — et un seul — suive la
convention : c'est le seul cas où l'on sait lequel Odoo recréera.
"""
if paire["a_ctr"] or paire["b_ctr"]:
return "constraint", None, None
table = paire["table"]
a_moderne = modern_name(table, paire["a"])
b_moderne = modern_name(table, paire["b"])
if a_moderne == b_moderne:
return "ambiguous", None, None
if a_moderne:
return "safe", paire["a"], paire["b"]
return "safe", paire["b"], paire["a"]
def find(database):
"""[(verdict, table, garder, supprimer, octets)]. None si base muette."""
sortie = run_psql(database, DETECTION)
if sortie is None:
return None
try:
paires = json.loads(sortie.strip() or "[]")
except ValueError:
return None
lst = []
for paire in paires:
verdict, garder, supprimer = classify(paire)
octets = (
paire["b_size"] if supprimer == paire["b"] else paire["a_size"]
)
lst.append(
(verdict, paire["table"], garder, supprimer, octets or 0, paire)
)
return sorted(lst, key=lambda x: (x[0], x[1], x[3] or ""))
def safe_only(lst):
return [item for item in lst if item[0] == "safe"]
def drop_sql(lst):
"""Les suppressions, une par ligne. Vide s'il n'y a rien de sûr.
`IF EXISTS` : deux paires peuvent nommer le même index à supprimer
quand une table en porte trois copies, et la deuxième passe ne doit
pas échouer sur ce qui vient de partir.
"""
noms = []
for _v, _tbl, _garder, supprimer, _o, _p in safe_only(lst):
if supprimer and supprimer not in noms:
noms.append(supprimer)
return "\n".join(f'DROP INDEX IF EXISTS "{nom}";' for nom in noms)
def render(lst, applique=False):
if not lst:
return [f"✅ {t('No duplicate index.')}"]
surs = safe_only(lst)
octets = sum(item[4] for item in surs)
lignes = [
f"🗂 {len(lst)} {t('duplicate index pair(s)')}"
f" — {len(surs)} {t('safe to drop')}"
f" ({octets // 1024} kB)",
"",
]
for _v, table, garder, supprimer, taille, _p in surs[:20]:
lignes.append(
f" {table} : {t('drop')} {supprimer}"
f" ({taille // 1024} kB), {t('keep')} {garder}"
)
if len(surs) > 20:
lignes.append(f" … {len(surs) - 20} {t('more')}")
for genre, texte in (
("constraint", t("backed by a constraint — left alone")),
("ambiguous", t("neither name follows the convention — your call")),
):
lot = [item for item in lst if item[0] == genre]
if not lot:
continue
lignes.append("")
lignes.append(f" ⚠ {len(lot)} {texte}")
for _v, table, _g, _s, _o, paire in lot[:6]:
lignes.append(f" {table} : {paire['a']} ↔ {paire['b']}")
if not applique and surs:
lignes.append("")
lignes.append(f" {t('Use --apply to drop them.')}")
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report the duplicate indexes Odoo 17 left behind when it"
" renamed its index convention, and optionally drop them."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--apply",
action="store_true",
help="actually drop the safe duplicates (default: report only)",
)
parser.add_argument("--json", action="store_true", help="machine output")
config = parser.parse_args(argv)
lst = find(config.database)
if lst is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if config.json:
print(
json.dumps(
[
{
"verdict": v,
"table": tbl,
"keep": g,
"drop": s,
"bytes": o,
}
for v, tbl, g, s, o, _p in lst
],
indent=2,
sort_keys=True,
)
)
return 1 if lst else 0
if not lst:
print("\n".join(render(lst)))
return 0
if not config.apply:
print("\n".join(render(lst)))
return 1
sql = drop_sql(lst)
if sql and run_psql(config.database, sql, read_only=False) is None:
print(f"❌ {t('The correction failed.')}")
return 2
# RELIRE : annoncer « supprimé » sans regarder ferait croire le
# problème réglé alors qu'un verrou a pu refuser la suppression.
apres = find(config.database)
if apres is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if safe_only(apres):
print("\n".join(render(apres, applique=True)))
print(f"⚠️ {t('Some duplicates are still there.')}")
return 1
print("\n".join(render(apres, applique=True)))
print(f"✅ {t('Duplicate indexes dropped.')}")
return 0
if __name__ == "__main__":
sys.exit(main())