[IMP] migration: replay a single version bump, and stop offering a doomed retry

When OpenUpgrade failed, todo_upgrade_execute offered the generic
« [1] to redo the command ». Replaying it re-ran OpenUpgrade on the database it
had just half migrated, which never recovers. That call now passes
wait_at_error=False, and the failure message explains that the clone step has
been reset so a relaunch DROPS and REBUILDS the intermediate database from the
previous version.

The resume menu gains « [4.N] »: replay the step 4 loop from one version bump.
Only the per-version lists are trimmed from that index, so earlier bumps stay
migrated and steps 0 to 3 are untouched. Resetting the clone entry is the whole
point: the intermediate database of a failed bump must be rebuilt, not upgraded
again. « [4] » still replays every bump.

The per-bump uninstall files were also read at step 1 only, for the source
version, so uninstall_module_list_odoo130_to_odoo140.txt existed in name but
was never read. The step 4 loop now reads the file of the bump it is about to
perform, merged with the answers already stored in the progression.

Verified against the real progression (13 and 14 migrated): the menu offers
13/14/15/16/17/18, and replaying from 14 turns every per-version list from
[True, True, False...] into [True, False, ...] while state_4_reach_open_upgrade
and steps 0-3 survive. A 13->14 list of 4 modules is read with its reasons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 04:46:17 -04:00
parent 1f0b5c021f
commit 11d9b88de9
2 changed files with 123 additions and 21 deletions

View file

@ -2523,6 +2523,15 @@ TRANSLATIONS = {
"fr": "Reprise à partir de l'étape",
"en": "Replaying from step",
},
"Replay the upgrade from version N": {
"fr": "Reprendre la montée à partir de la version N",
"en": "Replay the upgrade from version N",
},
"rebuilds the intermediate database": {
"fr": "recrée la base intermédiaire",
"en": "rebuilds the intermediate database",
},
"Unknown version": {"fr": "Version inconnue", "en": "Unknown version"},
"The progression file is invalid, ignoring it": {
"fr": "Le fichier de progression est invalide, on l'ignore",
"en": "The progression file is invalid, ignoring it",

View file

@ -186,11 +186,18 @@ class TodoUpgrade:
icon, detail = self.step_status(old_dct_progression, step)
print(f" [{step}] {icon} {t(label):<44} {detail}")
print()
lst_version = self.version_bumps(old_dct_progression)
print(f" [c] {t('Continue where it stopped')}")
print(
f" [0-4] {t('Replay from that step')}"
f" ({t('erases the progression of that step and the next ones')})"
)
if lst_version:
print(
f" [4.N] {t('Replay the upgrade from version N')}"
f" ({'/'.join(str(v) for v in lst_version)}) —"
f" {t('rebuilds the intermediate database')}"
)
print(f" [n] {t('New migration, erase everything')}")
print(f" [r] {t('Keep the zip only, ask every question again')}")
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
@ -204,6 +211,17 @@ class TodoUpgrade:
"migration_file": old_dct_progression.get("migration_file"),
"date_create": old_dct_progression.get("date_create"),
}, True
if answer.startswith("4.") and lst_version:
target = answer.split(".", 1)[1].strip()
if target.isdigit() and int(target) in lst_version:
return (
self.rewind_version_bump(
old_dct_progression, lst_version.index(int(target))
),
True,
)
print(f"⚠️ {t('Unknown version')} : {target}")
return old_dct_progression, False
if answer.isdigit() and 0 <= int(answer) <= MIGRATION_STEP[-1][0]:
return (
self.rewind_progression(old_dct_progression, int(answer)),
@ -213,6 +231,54 @@ class TodoUpgrade:
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
return old_dct_progression, False
@staticmethod
def version_bumps(dct_progression):
"""Odoo versions the step 4 loop walks through, e.g. [13, 14, ..., 18].
The per-version lists all end on the target, so the first bump is
« target - len + 1 ». Returns [] when step 4 has not started.
"""
total = max(
[
len(value)
for key, value in dct_progression.items()
if key.startswith("state_4_")
and key.endswith("_odoo_lst")
and isinstance(value, list)
]
or [0]
)
if not total:
return []
try:
last = int(float(dct_progression["target_odoo_version"]))
except (KeyError, TypeError, ValueError):
return []
return list(range(last - total + 1, last + 1))
@staticmethod
def rewind_version_bump(old_dct_progression, index):
"""Replay the step 4 loop from one version bump onwards.
Only the per-version lists are trimmed from `index`: earlier bumps stay
migrated and steps 0 to 3 are untouched. Resetting the clone entry is
the point the intermediate database of a failed bump is half
migrated, so it must be dropped and rebuilt from the previous version
rather than upgraded again.
"""
dct_kept = dict(old_dct_progression)
for key, value in old_dct_progression.items():
if (
key.startswith("state_4_")
and isinstance(value, list)
and key.endswith(("_odoo_lst", "_module"))
):
dct_kept[key] = [
item if i < index else False
for i, item in enumerate(value)
]
return dct_kept
@staticmethod
def rewind_progression(old_dct_progression, step):
"""Drop the progression of `step` and of every later step.
@ -916,20 +982,8 @@ class TodoUpgrade:
self.read_uninstall_module_list(start_version, database_name)
)
if lst_uninstall_reason:
# Show WHY each module goes away: which modules must be dropped
# depends on the database, and a removal without a stated reason
# is a decision nobody can review later.
print("✨ Modules to uninstall before migration :")
for name, reason, origin in lst_uninstall_reason:
print(
f" - {name}"
+ (
f"{reason}"
if reason
else " — ⚠️ no reason given"
)
+ f" [{origin}]"
)
self.print_uninstall_reason(lst_uninstall_reason)
if config_state_1_uninstall_module:
lst_module_to_uninstall = (
@ -1169,9 +1223,24 @@ class TodoUpgrade:
)
if not lst_module_uninstall_module[index]:
lst_module_to_uninstall = config_state_4_uninstall_module[
index
]
lst_module_to_uninstall = (
config_state_4_uninstall_module[index] or []
)
# Same file convention as step 1, one file per version bump:
# uninstall_module_list_odoo130_to_odoo140.txt is read HERE,
# right before the 13 -> 14 data migration. Without this the
# per-bump files existed in name only and were never read.
lst_file, lst_detail = self.read_uninstall_module_list(
next_version - 1, database_name
)
if lst_detail:
print(
f"✨ Modules to uninstall before Odoo{next_version} :"
)
self.print_uninstall_reason(lst_detail)
lst_module_to_uninstall = list(
dict.fromkeys(list(lst_module_to_uninstall) + lst_file)
)
if lst_module_to_uninstall:
self.uninstall_from_database(
@ -1735,11 +1804,17 @@ class TodoUpgrade:
# forced: their arch is a real customization.
self.neutralize_cow_views(database_name_upgrade, next_version)
# wait_at_error=False on purpose: the generic « [1] to redo the
# command » would replay OpenUpgrade on a database it has just
# half migrated, which never recovers. The failure is handled
# below by dropping the clone flag so the replay REBUILDS the
# intermediate database from the previous version.
status, cmd_executed = self.todo_upgrade_execute(
cmd_upgrade,
new_env={
"OPENUPGRADE_TARGET_VERSION": f"{next_version}.0"
},
wait_at_error=False,
)
# This is THE data migration. Recording it as done when it
@ -1757,11 +1832,15 @@ class TodoUpgrade:
)
self.write_config()
print(
f"❌ -> Database migration to Odoo{next_version} FAILED"
f" (status {status}). Stopping before version"
f" {next_version + 1} to avoid migrating a broken"
" database. Fix the cause, then relaunch: this version"
f" replays from a fresh clone of '{database_name}'."
f"\n❌ -> Database migration to Odoo{next_version}"
f" FAILED (status {status}).\n"
f" '{database_name_upgrade}' is now half migrated:"
" replaying the command on it would never recover, so"
" it is NOT offered.\n"
" The clone step has been reset. Fix the cause, then"
" relaunch the migration and answer [c] (continue):"
f" '{database_name_upgrade}' will be dropped and"
" rebuilt from the previous version before retrying."
)
return
@ -2046,6 +2125,20 @@ class TodoUpgrade:
lst_module.append((module_name, reason.strip()))
return lst_module
@staticmethod
def print_uninstall_reason(lst_detail):
"""Show WHY each module goes away.
Which modules must be dropped depends on the database, and a removal
without a stated reason is a decision nobody can review later.
"""
for name, reason, origin in lst_detail:
print(
f" - {name}"
+ (f"{reason}" if reason else " — ⚠️ no reason given")
+ f" [{origin}]"
)
def read_uninstall_module_list(self, start_version, database_name):
"""Modules to uninstall before migrating start_version -> next.