From 88102f79b85a585d26c1cf1e0ef68f0bd684db22 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 12:17:34 +0200 Subject: [PATCH 1/7] [OU-ADD] ptplus_accounting: pre-create l10n_pt_account_expected_balance The 15.0.5.0.0 vendor upgrade adds this stored computed field on account.move.line. On a large database the ORM's column-creation mass compute ("Storing computed values") loads millions of rows into the cache on top of ~300 modules' worth of upgrade state and the process is OOM-killed. Pre-creating the column in a pre-migration makes _auto_init skip the mass init (the openupgradelib.add_fields mechanism); the field is recomputed in controlled batches at the target version. --- .../15.0.5.0.0/pre-migration.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-migration.py diff --git a/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-migration.py b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-migration.py new file mode 100644 index 00000000000..b3816f5895b --- /dev/null +++ b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-migration.py @@ -0,0 +1,17 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + # The 15.0.5.0.0 upgrade adds the stored computed field + # account_move_line.l10n_pt_account_expected_balance. Creating the column + # here makes the ORM skip its whole-table "Storing computed values" pass + # (millions of rows), which cannot fit in memory during `-u all`. The + # field is recomputed in controlled batches at the target version. + openupgrade.logged_query( + env.cr, + """ + ALTER TABLE account_move_line + ADD COLUMN IF NOT EXISTS l10n_pt_account_expected_balance numeric + """, + ) From 7f91084d094808480122223960bec3797e0a23ba Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 12:17:34 +0200 Subject: [PATCH 2/7] [OU-ADD] ptplus_stock: pre-create l10n_pt_is_national The 15.0.5.0.0 vendor upgrade adds this stored computed field on stock.picking. Same mass-compute OOM as ptplus_accounting's l10n_pt_account_expected_balance: pre-creating the column makes the ORM skip the whole-table init; the field is recomputed in controlled batches at the target version. --- .../ptplus_stock/15.0.5.0.0/pre-migration.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/pre-migration.py diff --git a/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/pre-migration.py b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/pre-migration.py new file mode 100644 index 00000000000..39f18b6e7f2 --- /dev/null +++ b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/pre-migration.py @@ -0,0 +1,17 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + # The 15.0.5.0.0 upgrade adds the stored computed field + # stock_picking.l10n_pt_is_national. Creating the column here makes the + # ORM skip its whole-table "Storing computed values" pass (millions of + # rows), which cannot fit in memory during `-u all`. The field is + # recomputed in controlled batches at the target version. + openupgrade.logged_query( + env.cr, + """ + ALTER TABLE stock_picking + ADD COLUMN IF NOT EXISTS l10n_pt_is_national boolean + """, + ) From cc3111e95b0b8b33f242fc379e51ddbba60899a0 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 12:24:16 +0200 Subject: [PATCH 3/7] [OU-FIX] ptplus_accounting: drop stale vat_adjustment_norm_id duplicate before rename Databases that already carry l10n_pt_vat_adjustment_norm_id alongside the old vat_adjustment_norm_id duplicate crash the module's own pre-migration: its openupgrade.rename_fields() finds the target column already present. Drop the old duplicate first, guarded to abort if it holds any data. The pre-00- basename makes it run before the module's own pre-migration.py (same-version stage scripts execute in basename order). --- .../pre-00-dedup-vat-adjustment-norm.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-00-dedup-vat-adjustment-norm.py diff --git a/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-00-dedup-vat-adjustment-norm.py b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-00-dedup-vat-adjustment-norm.py new file mode 100644 index 00000000000..22fd3a7dcea --- /dev/null +++ b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/pre-00-dedup-vat-adjustment-norm.py @@ -0,0 +1,53 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + # Some 14.0 databases already carry l10n_pt_vat_adjustment_norm_id (the + # 15.0 target name) while the old vat_adjustment_norm_id duplicate also + # exists on account_move. The module's own pre-migration then calls + # openupgrade.rename_fields() old -> new and fails because the target + # exists. Drop the old duplicate first, but only when it is provably + # empty. Named pre-00-* so it sorts — and therefore runs — before the + # module's own pre-migration.py (same-version stage scripts execute in + # basename order). + cr = env.cr + if not ( + openupgrade.column_exists(cr, "account_move", "vat_adjustment_norm_id") + and openupgrade.column_exists( + cr, "account_move", "l10n_pt_vat_adjustment_norm_id" + ) + ): + return + cr.execute( + "SELECT count(*) FROM account_move WHERE vat_adjustment_norm_id IS NOT NULL" + ) + old_non_null = cr.fetchone()[0] + if old_non_null: + raise RuntimeError( + "Refusing to drop account_move.vat_adjustment_norm_id: " + "%s non-null rows" % old_non_null + ) + openupgrade.logged_query( + cr, + """ + DELETE FROM ir_model_data + WHERE model = 'ir.model.fields' + AND res_id IN ( + SELECT id FROM ir_model_fields + WHERE model = 'account.move' + AND name = 'vat_adjustment_norm_id' + ) + """, + ) + openupgrade.logged_query( + cr, + """ + DELETE FROM ir_model_fields + WHERE model = 'account.move' + AND name = 'vat_adjustment_norm_id' + """, + ) + openupgrade.logged_query( + cr, "ALTER TABLE account_move DROP COLUMN vat_adjustment_norm_id" + ) From 2611ceb444da126ee0b0b0711c1c2babffb57e95 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 12:58:47 +0200 Subject: [PATCH 4/7] [OU-ADD] ptplus_accounting: batch-recompute l10n_pt_account_expected_balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the field pre-created empty by pre-migration.py, in memory-safe chunks using the model's own compute via the ORM (add_to_compute + flush), so the vendor's real compute logic runs without ever reading the packaged code. Runs in post-migration at 15.0.5.0.0, so the field is created AND filled within this same hop — no cross-version dependency, each hop self-contained. --- ...ompute-l10n-pt-account-expected-balance.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py diff --git a/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py new file mode 100644 index 00000000000..96641d3c803 --- /dev/null +++ b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py @@ -0,0 +1,44 @@ +import logging + +from openupgradelib import openupgrade + +_logger = logging.getLogger(__name__) + +# Kept small so each browse+compute+flush cycle stays within the container +# memory budget: the cache is dropped between chunks (invalidate_cache), so +# peak memory is one chunk, not the whole table. +CHUNK = 5000 + + +def _batched_recompute(env, model_name, fname): + """Recompute one stored computed field over the whole table, in + memory-safe chunks, using the model's OWN compute method via the ORM. + + The column was pre-created empty in pre-migration to skip the ORM's + whole-table mass init (which OOM-kills the upgrade). Here we fill it: the + compute is triggered through the ORM (add_to_compute + flush), so the + vendor's real compute logic runs — we never need to read or reimplement + it. Self-contained: the field is created AND filled within this same hop. + """ + model = env[model_name].with_context(active_test=False, prefetch_fields=False) + field = model._fields[fname] + env.cr.execute('SELECT id FROM "%s" ORDER BY id' % model._table) + ids = [row[0] for row in env.cr.fetchall()] + total = len(ids) + _logger.info( + "recompute %s.%s over %s rows (chunk %s)", model_name, fname, total, CHUNK + ) + for start in range(0, total, CHUNK): + recs = model.browse(ids[start : start + CHUNK]) + env.add_to_compute(field, recs) + recs.flush([fname], recs) + env.cr.commit() + recs.invalidate_cache() + if start and start % (CHUNK * 20) == 0: + _logger.info(" ... %s/%s", start, total) + _logger.info("recompute %s.%s done", model_name, fname) + + +@openupgrade.migrate() +def migrate(env, version): + _batched_recompute(env, "account.move.line", "l10n_pt_account_expected_balance") From b35357c30711ecb9fed446a92453d43095e3ce01 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 12:58:47 +0200 Subject: [PATCH 5/7] [OU-ADD] ptplus_stock: batch-recompute l10n_pt_is_national MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the field pre-created empty by pre-migration.py, in memory-safe chunks using the model's own compute via the ORM (add_to_compute + flush), so the vendor's real compute logic runs without ever reading the packaged code. Runs in post-migration at 15.0.5.0.0, so the field is created AND filled within this same hop — no cross-version dependency, each hop self-contained. --- .../post-recompute-l10n-pt-is-national.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py diff --git a/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py new file mode 100644 index 00000000000..fefe2fb32bd --- /dev/null +++ b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py @@ -0,0 +1,44 @@ +import logging + +from openupgradelib import openupgrade + +_logger = logging.getLogger(__name__) + +# Kept small so each browse+compute+flush cycle stays within the container +# memory budget: the cache is dropped between chunks (invalidate_cache), so +# peak memory is one chunk, not the whole table. +CHUNK = 5000 + + +def _batched_recompute(env, model_name, fname): + """Recompute one stored computed field over the whole table, in + memory-safe chunks, using the model's OWN compute method via the ORM. + + The column was pre-created empty in pre-migration to skip the ORM's + whole-table mass init (which OOM-kills the upgrade). Here we fill it: the + compute is triggered through the ORM (add_to_compute + flush), so the + vendor's real compute logic runs — we never need to read or reimplement + it. Self-contained: the field is created AND filled within this same hop. + """ + model = env[model_name].with_context(active_test=False, prefetch_fields=False) + field = model._fields[fname] + env.cr.execute('SELECT id FROM "%s" ORDER BY id' % model._table) + ids = [row[0] for row in env.cr.fetchall()] + total = len(ids) + _logger.info( + "recompute %s.%s over %s rows (chunk %s)", model_name, fname, total, CHUNK + ) + for start in range(0, total, CHUNK): + recs = model.browse(ids[start : start + CHUNK]) + env.add_to_compute(field, recs) + recs.flush([fname], recs) + env.cr.commit() + recs.invalidate_cache() + if start and start % (CHUNK * 20) == 0: + _logger.info(" ... %s/%s", start, total) + _logger.info("recompute %s.%s done", model_name, fname) + + +@openupgrade.migrate() +def migrate(env, version): + _batched_recompute(env, "stock.picking", "l10n_pt_is_national") From 09ab85fef3809e6013948c0b405ea50fa326fc40 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 14:30:20 +0200 Subject: [PATCH 6/7] [OU-FIX] ptplus_accounting: no commit inside the batched recompute @openupgrade.migrate() wraps the script in a savepoint: a cr.commit() per chunk destroys it (RELEASE SAVEPOINT crashes on script exit, aborting the whole upgrade at the finish line) and breaks per-module transactionality. Memory stays bounded by the per-chunk cache invalidation alone; the recompute measured ~3 minutes for 2.28M rows. --- .../post-recompute-l10n-pt-account-expected-balance.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py index 96641d3c803..a9c848b1008 100644 --- a/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py +++ b/openupgrade_scripts/scripts/ptplus_accounting/15.0.5.0.0/post-recompute-l10n-pt-account-expected-balance.py @@ -6,7 +6,10 @@ # Kept small so each browse+compute+flush cycle stays within the container # memory budget: the cache is dropped between chunks (invalidate_cache), so -# peak memory is one chunk, not the whole table. +# peak memory is one chunk, not the whole table. No commit inside the loop: +# @openupgrade.migrate() wraps the script in a savepoint (a commit would +# destroy it -> RELEASE SAVEPOINT crashes on exit) and the module upgrade +# must stay transactional; flushed rows live in the module's transaction. CHUNK = 5000 @@ -32,7 +35,6 @@ def _batched_recompute(env, model_name, fname): recs = model.browse(ids[start : start + CHUNK]) env.add_to_compute(field, recs) recs.flush([fname], recs) - env.cr.commit() recs.invalidate_cache() if start and start % (CHUNK * 20) == 0: _logger.info(" ... %s/%s", start, total) From 691db967b8e4530a69c1b3e00abbbc3254255591 Mon Sep 17 00:00:00 2001 From: Eric Antones Date: Mon, 6 Jul 2026 14:30:20 +0200 Subject: [PATCH 7/7] [OU-FIX] ptplus_stock: no commit inside the batched recompute Same savepoint/transactionality fix as ptplus_accounting: the decorator wraps the script in a savepoint that a mid-loop commit destroys. Cache invalidation per chunk alone keeps memory bounded. --- .../15.0.5.0.0/post-recompute-l10n-pt-is-national.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py index fefe2fb32bd..4cca928993c 100644 --- a/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py +++ b/openupgrade_scripts/scripts/ptplus_stock/15.0.5.0.0/post-recompute-l10n-pt-is-national.py @@ -6,7 +6,10 @@ # Kept small so each browse+compute+flush cycle stays within the container # memory budget: the cache is dropped between chunks (invalidate_cache), so -# peak memory is one chunk, not the whole table. +# peak memory is one chunk, not the whole table. No commit inside the loop: +# @openupgrade.migrate() wraps the script in a savepoint (a commit would +# destroy it -> RELEASE SAVEPOINT crashes on exit) and the module upgrade +# must stay transactional; flushed rows live in the module's transaction. CHUNK = 5000 @@ -32,7 +35,6 @@ def _batched_recompute(env, model_name, fname): recs = model.browse(ids[start : start + CHUNK]) env.add_to_compute(field, recs) recs.flush([fname], recs) - env.cr.commit() recs.invalidate_cache() if start and start % (CHUNK * 20) == 0: _logger.info(" ... %s/%s", start, total)