Show pageDiscussionOld revisionsBacklinksBack to top This page is read only. You can view the source, but not change it. Ask your administrator if you think this is wrong. ====== Odoo Multi-Version Migration: 11.0 → 18.0 (OpenUpgrade) ====== This page documents the automated migration chain used to bring the **luxy** production database from Odoo 11.0 all the way to 18.0 using [[https://github.com/OCA/OpenUpgrade|OCA OpenUpgrade]], one major version at a time. It covers prerequisites, project layout, how to run a stage, and every non-obvious fix that was needed along the way — so a future run (or a run against a different database on this same chain) doesn't have to rediscover them. ~~NOTOC~~ ===== 1. Overview ===== Odoo (and OpenUpgrade) only supports migrating **one major version at a time** — there is no direct 11.0 → 18.0 path. The chain implemented here is: 11.0 -> 12.0 -> 13.0 -> 14.0 -> 15.0 -> 16.0 -> 17.0 -> 18.0 Each hop: - Duplicates the previous stage's database (cheap, via ''CREATE DATABASE ... WITH TEMPLATE''). - Runs that version's pre-migration SQL fixes (only needed for the early, hand-written-SQL stages). - Runs the real OpenUpgrade module migration (''odoo -u all'') inside that version's own container. - Auto-recovers from a library of known, safe failure signatures (see [[#5_generic_auto-recovery_patterns|Generic auto-recovery patterns]]). - Applies any additional known, stage-specific fixes. - Cleans up modules left in a broken state, clears the compiled asset cache, and reports the final module state. All of this is orchestrated by a single script: **''run_upgrade_stage.sh''**. ===== 2. Prerequisites ===== * **Docker + Docker Compose** (v2, ''docker compose'' not ''docker-compose''). * **A single shared PostgreSQL instance** (this project uses ''postgres:14.5'' for every version — Odoo talks to it over the network, the server version doesn't need to match the Odoo version). * **Disk space**: each version's Docker image is ~1.3–2.3GB, growing with version. Budget **at least 5GB free** before starting a new stage; the full chain (11 through 18, all images built) needs tens of GB. See [[#8_disk_space_management|Disk space management]]. * **Per-version OpenUpgrade checkout** under ''<version>/openupgrade'' — either the old per-module ''migrations/'' convention (11.0–13.0) or the centralized ''openupgrade_scripts/'' + ''openupgrade_framework'' convention (14.0+). These are normally provided by a sibling project's ''init.sh'' (clones ''OCA/OpenUpgrade'' at the matching branch). * **bash 5.x** on the host running ''run_upgrade_stage.sh'' (uses ''set -euo pipefail''; see the note about ''local var'' under ''set -u'' in [[#9_gotchas_worth_remembering|Gotchas worth remembering]]). * ''psql'' access to the shared postgres container from the host (script shells out via ''docker exec postgres psql''). ===== 3. Project Structure ===== <code> oca_openupgrade/ ├── docker-compose.yaml # one service per Odoo version + shared postgres + nginx-proxy ├── run_upgrade_stage.sh # the master automation script (see below) ├── pre_migration_cleanup.sql # 11->12 pre-migration SQL fixes ├── post_migration_cleanup.sql # 11->12 post-migration SQL fixes (asset cache, etc.) ├── migration12to13.sql # 12->13 pre-migration SQL fixes ├── migration13to14_new.sql # 13->14 pre-migration SQL fixes ├── upgrade_logs/ # every stage's driver + per-attempt logs ├── 11.0/ … 18.0/ # per-version: │ ├── config/odoo.conf # Odoo config (addons_path, db creds, admin_passwd) │ ├── addons/ # extra/private addons for that version │ ├── openupgrade/ # OCA OpenUpgrade checkout for that version │ └── odooXX-data/ # bind-mounted /var/lib/odoo (filestore, sessions) └── fix_corrupted_view_translations.sql # standalone fixes, portable to any server └── cleanup_leftover_uninstalled_modules.py # (see section 7) </code> ==== 3.1 docker-compose.yaml ==== * One service per version: ''odoo11'' … ''odoo18'' (odoo19 defined but not yet used). * A single ''postgres'' service shared by every version. * Only the versions currently being worked on need to be **uncommented** — once a stage is complete and verified, its container/image can be stopped and removed to save disk (see section 8). The corresponding block in ''docker-compose.yaml'' can be commented back out; the database itself lives in Postgres independently of the container. * ''odoo11'' uses the stock ''odoo:11.0'' image (no OpenUpgrade lib needed at that version's image level — OpenUpgrade there is just an addons-path checkout). ''odoo12''+ use a custom ''Dockerfile'' that installs ''openupgradelib'' on top of the base Odoo image. ==== 3.2 Per-version odoo.conf ==== Two config bugs recur across versions and are worth checking **every time a new version's container is brought up for the first time**: - **A stray space in ''addons_path''** (e.g. ''/mnt/openupgrade , /mnt/extra-addons''). Odoo's HTTP static-file bootstrap (''load_addons()'' / the ''Application.statics'' property in ''odoo/http.py'') does a raw, unguarded ''os.listdir()'' over every ''addons_path'' entry. A trailing-space entry that doesn't exist as a real directory throws an uncaught exception that **silently disables all static file serving for the life of the process** — the page loads with zero CSS/JS, and every static asset 404s, with no error in the Odoo log beyond the very first request. Fix: remove the stray space/comma so every entry is a real, listable directory. - The same class of bug: a **nonexistent** ''addons_path'' entry entirely (e.g. a leftover reference to a since-uninstalled OCA module's own repo path). Same symptom, same fix — remove the dead entry. <code bash> docker exec -u root <container> sed -i 's#/mnt/openupgrade ,#/mnt/openupgrade,#' /etc/odoo/odoo.conf docker restart <container> </code> ==== 3.3 run_upgrade_stage.sh ==== Usage: <code bash> ./run_upgrade_stage.sh <stage> <src_db> <dst_db> # Stages: 11to12 | 12to13 | 13to14 | 14to15 | 15to16 | 16to17 | 17to18 ./run_upgrade_stage.sh 11to12 luxy_prod luxy_11to12 ./run_upgrade_stage.sh 12to13 luxy_11to12 luxy_12to13 ./run_upgrade_stage.sh 13to14 luxy_12to13 luxy_13to14 ./run_upgrade_stage.sh 14to15 luxy_13to14 luxy_14to15 ./run_upgrade_stage.sh 15to16 luxy_14to15 luxy_15to16 ./run_upgrade_stage.sh 16to17 luxy_15to16 luxy_16to17 ./run_upgrade_stage.sh 17to18 luxy_16to17 luxy_17to18 </code> Pipeline run by ''main()'' for every stage: - ''ensure_container_env'' — filestore ownership (''chown odoo:odoo /var/lib/odoo''), the ''odoo.openupgrade'' stub for 11–13 (see 4.1), and any stage-specific **container source patches** (see each stage's notes below). - ''recreate_db'' — terminates connections, ''DROP DATABASE'', ''CREATE DATABASE ... WITH TEMPLATE <src_db>''. - ''run_pre_sql'' — the stage's hand-written pre-migration SQL file, if any (only 11→12, 12→13, 13→14 have one). - ''apply_known_fixes_<stage>'' — proactive, stage-specific SQL fixes applied **before** the migration starts (see each stage's section). - ''run_migration'' — the actual ''odoo -u all --stop-after-init'' run, in a retry loop (up to 60 attempts) that calls ''try_generic_recovery()'' after every failure (section 5). - On success: ''cleanup_stuck_modules'', ''cleanup_leftover_uninstalled_modules'', ''fix_corrupted_view_translations'' (sections 6–7), ''run_post_sql'', ''clear_asset_cache'', ''report_state''. ===== 4. Stage-by-Stage Notes ===== ==== 4.1 11 → 12 ==== * **addons_path ordering bug**: the 11/12 images run vanilla Odoo with the OpenUpgrade addon tree mounted alongside it. Odoo resolves each module name to a **single** winning ''addons_path'' directory (used for both code *and* migrations, no fallback). Community addons (repair, sale, stock, account, …) must resolve to ''/mnt/openupgrade/addons'' **first**; core modules (''base'', etc.) must resolve to ''/mnt/openupgrade/odoo/addons'' first, so the ''base'' module's own pre-migration (which does the module-rename/merge bookkeeping many other modules depend on) actually runs. * ''odoo.openupgrade'' **stub**: the ''base'' module's OpenUpgrade code imports ''odoo.openupgrade'' — a small pair of helper files that doesn't exist in vanilla ''dist-packages'' Odoo. Copied in once per container, idempotent. * **point_of_sale demo data crash**: ''point_of_sale/12.0.1.0.1/noupdate_changes.xml'' writes ''default_code'' on an existing demo product, triggering a ''KeyError'' deep in the computed-field registry. Fix: drop that one ''<field>'' line. * ''account_asset'' **vs** ''account_asset_management'' (OCA): mutually exclusive; force ''account_asset'' to ''uninstalled'' so the OCA migration can take over its tables. * **Premature renames reversed**: the original pre-migration SQL pre-emptively renamed ''hr_holidays(_status)'' and ''procurement_rule'' as a workaround for the addons_path bug above. Once that root cause was fixed, those premature renames had to be **reversed** so the real OpenUpgrade scripts could do the full transformation themselves. * **Space-corrupted xmlids**: a data-quality artifact (xml_id ''name'' containing a literal space) that breaks lookups the target version's data files expect by the correct (underlying) name. Generic fix, reused every stage after. ==== 4.2 12 → 13 ==== * **toggle_active anchor injection**: Odoo 13 replaced the old ''<button name="toggle_active">'' on ''res.partner''/''hr.employee''/''product.*'' forms with a ''web_ribbon'' widget, but several still-current modules ship views that xpath-anchor off that button. A minimal invisible one is re-injected into 5 core view files via an ''lxml''-based helper. * **Stale xpath removal**: the product views shipped by ''sale'' and by ''stock_account'' each xpath onto elements (%%//group[@name='invoicing']%%, a button referenced by resolved action id) that no longer reliably exist/resolve in this hybrid environment. Removed just those xpath blocks via regex. * **Lang code renames** (''ar_AA''→''ar_001'', ''fil''→''fil_PH'') and **25 module-category renames** applied **pre-emptively**, because the ''base'' module's own bootstrap data (loaded *before* any migration script runs) already references the new names/codes. * **Image field renames** (''image''→''image_1920'', etc.) applied the same way, for the same bootstrap-timing reason. * **''account_move'' classification gap** (the big one): the ''account'' module's own ''account_invoice'' → ''account_move'' migration was found to silently not run — every ''account_move'' row ended up as generic type ''entry'', making every invoice/bill **invisible in the Invoicing app** despite the underlying accounting data being intact. Fixed by restoring ''account_move.type'' from ''account_invoice.type'' via the surviving ''account_invoice.move_id'' link, for every invoice that was actually posted. * **432 draft/cancelled invoices** never had a posted move in the old system, so the fix above didn't touch them. A separate one-off Python script rebuilt these as proper ''account.move'' records (with lines and taxes) via the ORM, once the user asked for it — 427 of 432 succeeded (5 had zero original line items, nothing to reconstruct). * **7 modules improperly "installed"** with no real v13 code (''account_voucher'', ''account_cancel'', ''crm_phone_validation'', ''decimal_precision'', ''document'', ''web_environment_ribbon'', ''web_settings_dashboard''): a plain SQL state flip to ''uninstalled'' is **not enough** — their views/menus/actions linger and crash the UI (e.g. an orphaned "Vendor Bills" action still pointing at the long-gone ''account.voucher'' model). The correct fix is a **real uninstall through the ORM** (''button_immediate_uninstall()'' via ''odoo shell''), which cascades and removes everything tracked under that module's xmlids in dependency order. This became the standard pattern for every later stage too. ==== 4.3 13 → 14 ==== * From 14.0 onward, OpenUpgrade switches from the old per-module ''migrations/'' convention to a **centralized** ''openupgrade_scripts/scripts'' tree, located via ''--upgrade-path''. It also ships an ''openupgrade_framework'' server-wide module that patches core ORM/loading behaviour (view-inheritance handling among other things) — **required** via ''--load=web,openupgrade_framework''. Without it, migrations run but core patches are missing, causing otherwise-unexplained QWeb ''ParseError''s. * **mass_mailing table-rename bug**: a hand-patched (non-upstream) ''pre-migration.py'' in this OpenUpgrade checkout handled the ''mail_mass_mailing'' → ''mailing_mailing'' table rename but was **missing** the analogous ''mail_mass_mailing_list'' → ''mailing_list'' rename. Without it, the ORM auto-creates an empty ''mailing_list'' table, the rel-table column rename proceeds anyway, and ''check_foreign_keys()'' fails (old list ids referencing the new, empty table). Fixed by adding the missing ''_handle_mailing_list_table_rename'' function, mirroring the existing one. * **Multi-company picking-type mismatches**: several ''stock.picking.type'' rows had a ''company_id'' that didn't match their own warehouse's company (legacy from an earlier company split/archive — an "archived" 2ES warehouse under a different company than its picking types). Invisible until a v14 data file (a function in ''point_of_sale'' called %%_create_missing_pos_picking_types%%) writes one of them, triggering ''_check_company()''. Fix: realign ''company_id'' to the warehouse's company — but only where doing so doesn't create a *new* mismatch against a sequence genuinely **shared** across different companies' warehouses (in which case, null out that sequence's ''company_id'' instead, matching Odoo's own convention for company-agnostic shared resources). * ''l10n_dz'' ended up stuck with no installable code — cleaned up via the standard ORM-uninstall pattern. ==== 4.4 14 → 15 ==== * **Module rename collision**: ''payment_ingenico'' → ''payment_ogone'', but a stale, empty ''payment_ogone'' row already existed from an earlier hop. Both uninstalled, zero attached data — safe to clear the collision and let the rename proceed. Generalized into a new recovery pattern (see 5, pattern a2). * ''hr_holidays'' **v15 allocation constraint**: a new ''_check_allocation_id'' constraint requires every "employee"-type leave request to have a matching ''hr.leave.allocation'' — but that requirement is new in v15, and this business's historical data has far more validated leave requests than matching allocations for several leave types (formal allocation tracking was simply never enforced in the old system). Fixed by pre-emptively — and, for any type the heuristic misses, generically via a recovery pattern — setting %%requires_allocation='no'%% for the affected leave type(s). * **Non-idempotent ''openupgrade_legacy_*'' columns**: several migration scripts call openupgradelib's ''copy_columns()'' to stash an old column under a new name before transforming it — not retry-safe (no ''IF NOT EXISTS'' guard). A prior attempt that got far enough already created the column; the next retry's raw ''ADD COLUMN'' then crashes on ''DuplicateColumn''. Generic fix: drop the stale column and let ''copy_columns()'' recreate it. * ''account.payment'' **constraint firing before its own backfill**: ''_check_payment_method_line_id'' requires that field to be set on every payment, but it's only backfilled by the ''account'' module's own ''end-migration.py'' (which explicitly runs **last**). The constraint fires earlier anyway, during an unrelated module's registry-init flush — a false positive against records still awaiting that backfill. Patched the constraint to skip while ''self.env.registry.ready'' is still ''False''. * **Orphaned duplicate view bug (root-caused here, fixed for good)**: clearing a colliding ''ir_model_data'' row (recovery pattern a) only removed the *pointer* — the underlying, now-orphaned ''ir.ui.view'' row was never deleted, and Odoo still combines it into its model's inheritance chain regardless of xmlid tracking. Confirmed: 25 such orphans had silently accumulated, each a stale duplicate of a properly-tracked view recreated under the same xmlid, and one of them (a stale v14-era xpath) crashed the Contacts page. **Fixed generically**: pattern (a) now also deletes the underlying ''ir_ui_view'' row when the colliding record's model is ''ir.ui.view''. ==== 4.5 15 → 16 ==== * **Duplicate ''resource_calendar_attendance'' rows**: exact-content duplicates (same calendar/day/hours/name, different id) accumulated somewhere earlier in the chain, tripping v16's ''_check_overlap'' ("Attendances can't overlap") — not a real scheduling conflict. Generic fix: de-duplicate, keeping the lower id. * ''google_drive''**/**''google_spreadsheet'' **stuck with a missing model class**: marked "installed" but the standard ORM-uninstall path itself crashed (%%KeyError: 'google.drive.config'%% — the model genuinely wasn't registered in the live registry to cascade through). Cleaned up via manual, careful SQL cascade instead (verified zero real business data attached first — a single inactive demo config row). ==== 4.6 16 → 17 ==== * **Repair module** (the toughest single issue in the whole chain): v17's backfill of ''repair.order'' picking types/locations crashed on ''NOT NULL'' violations from two separate angles: - A warehouse whose ''repair_type_id'' was **already set** before the creation step ran (exact trigger not fully traced — confirmed via debug instrumentation that only 3 of 4 warehouses got the expected creation log line), so its `default_remove_location_dest_id`-derived value ended up ''NULL''. - ~238 repair orders whose source ''location_id'' isn't tied to **any** warehouse at all (e.g. the global "Scrapped" virtual location) — these never matched the migration's ''JOIN'' at all and kept pointing at a temporary placeholder that later got deleted, cascading a ''SET NULL'' onto ''NOT NULL'' columns. - Fixed with a full ''COALESCE'' fallback chain (falling back to the warehouse's own stock location, and ultimately to the repair order's own location) plus switching the update's ''JOIN''s to ''LEFT JOIN'' so every row is covered — patched directly into the openupgrade script file. * **Analytic plans**: v17's analytic-accounting overhaul expects a default "Projects" analytic plan pointed to by the ''analytic.project_plan'' system parameter. This environment only had a "Legacy" plan. Pre-created the "Projects" plan, the config parameter, and backfilled ''parent_path'' for the new materialized-path tree column (from prior manual-migration notes on another database of the same version jump). * ''arch_db'' **became ''jsonb''** around this version — the existing "stale xpath" recovery pattern's SQL (''arch_db LIKE ...'') broke with a Postgres type error. Fixed with an explicit ''::text'' cast. ==== 4.7 17 → 18 ==== * Migration itself succeeded cleanly on the first attempt — no new migration-time issues. * **Corrupted view translations discovered post-migration** (traced back to the 14→15 hop): a handful of views have a translation key (''fr_FR'', ''ar_SY'', …) whose value is a **bare translated string** (e.g. a button's label) instead of the view's translated XML arch — a stale/mismatched ''ir.translation'' row incorrectly consolidated into the wrong jsonb slot, most likely during the old ''ir_translation'' → jsonb migration. Combining that view for a user on that locale then crashes outright (''XMLSyntaxError: Start tag expected''). Fixed by stripping any jsonb key that isn't XML-shaped (falls back to a working language). See section 7 for the standalone fix. * **Modules marked "uninstalled" since the very first (11→12) stage, still leaking live data**: ''account_budget_oca'', ''anonymization'', ''log_forwarded_for_ip'', ''website_mail'', ''survey'', ''survey_crm''. Their views/menus/etc. were **never actually cleaned up** and silently rode along through all 7 subsequent migration hops, undetected until a French-locale user opened Settings and a lingering, still-active view from ''account_budget_oca'' (named %%res_config_settings_view_form%%) had an xpath that no longer matched the current form. Fixed via the same real-ORM-uninstall pattern used throughout. See section 7. ===== 5. Generic auto-recovery patterns ===== ''try_generic_recovery()'' inspects the last failed attempt's log for a known-safe signature and applies a fix automatically, so the retry loop can just try again. In order of appearance in the script: ^ Pattern ^ Signature ^ Fix ^ | a0 | ''DuplicateColumn'' on an ''openupgrade_legacy_*'' backup column | Drop the column; ''copy_columns()'' recreates it cleanly | | a1b | "Attendances can't overlap" / "présences ne peuvent pas se chevaucher" | De-duplicate exact-copy ''resource_calendar_attendance'' rows | | a1 | "Could not find an allocation of type X…" | %%UPDATE hr_leave_type SET requires_allocation='no'%% for that type | | a | ''ir_model_data_module_name_uniq_index'' violation | Delete the colliding xmlid row (**and** the underlying ''ir.ui.view'' row if that's the model) | | a2 | ''ir_module_module_name_uniq'' violation | Delete the stale duplicate module row, if uninstalled with no attached data | | b | ''ir_model_relation_model_fkey'' | Delete the obsolete model's own m2m relation-bookkeeping row | | c | ''ir_cron_ir_actions_server_id_fkey'' | Delete the stale cron pointing at the obsolete server action | | d | Any other ''DELETE'' blocked by a real FK | **Protect** the record (''noupdate=true'') instead of forcing the delete — assume it's real, still-referenced data | | e | A view write fails validation mid-load (misattributed to the wrong view_id) | Search all views for the offending field/element; delete if genuinely orphaned (not defined in current module source), otherwise protect via ''noupdate'' | The guiding philosophy throughout: **never force-delete something that might be real data** — protect it (''noupdate=true'') and move on. Every "detected orphan → delete" case was explicitly verified (module source check, zero-attached-data check, or an exact-duplicate check) before being made automatic. ===== 6. Post-migration cleanup (runs after every successful stage) ===== * **''cleanup_stuck_modules''**: modules left in a state other than ''installed''/''uninstalled''/''uninstallable'' (typically ''to upgrade'') because OpenUpgrade's rename table points them at code that isn't actually present. A plain state flip is not enough — real ORM uninstall via ''odoo shell'' + ''button_immediate_uninstall()''. * **''cleanup_leftover_uninstalled_modules''** *(added after the 17→18 discovery)*: sweeps for modules **already** marked ''uninstalled'' that still have live ''ir_model_data''-tracked records — a state ''cleanup_stuck_modules'' never checks (it only looks at *non*-clean states). Same real-ORM-uninstall fix. * **''fix_corrupted_view_translations''** *(added after the 17→18 discovery)*: strips any ''ir_ui_view.arch_db'' jsonb key whose value isn't XML-shaped. No-ops harmlessly on pre-jsonb (11→12 through 13→14) stages. * **''clear_asset_cache''**: deletes compiled ''web.assets*'' ''ir_attachment'' rows and restarts the container, so the next page load recompiles fresh bundles. ===== 7. Standalone fixes (portable to any server) ===== Two of the fixes discovered on 17→18 are **generic** (auto-detect, no hardcoded database or module names) and safe to run against **any** Odoo 18 (or later jsonb-arch) database, independent of this project's docker-compose setup: ==== 7.1 fix_corrupted_view_translations.sql ==== <code sql> BEGIN; UPDATE ir_ui_view v SET arch_db = ( SELECT jsonb_object_agg(kv.key, kv.value) FROM jsonb_each_text(v.arch_db) AS kv WHERE kv.value ~ '^\s*(<\?xml|<)' ) WHERE EXISTS ( SELECT 1 FROM jsonb_each_text(v.arch_db) AS kv2 WHERE kv2.value !~ '^\s*(<\?xml|<)' ); COMMIT; </code> Run with: <code bash> docker exec -i postgres psql -U odoo -d <DB_NAME> < fix_corrupted_view_translations.sql </code> ==== 7.2 cleanup_leftover_uninstalled_modules.py ==== <code python> env.cr.execute(""" SELECT DISTINCT m.name FROM ir_module_module m JOIN ir_model_data d ON d.module = m.name WHERE m.state = 'uninstalled' """) leftover_names = [r[0] for r in env.cr.fetchall()] if leftover_names: mods = env['ir.module.module'].search([('name', 'in', leftover_names)]) mods.write({'state': 'installed'}) mods.button_immediate_uninstall() env.cr.commit() </code> Run with: <code bash> docker exec -i <ODOO_CONTAINER> odoo shell -d <DB_NAME> --no-http < cleanup_leftover_uninstalled_modules.py </code> Run the SQL fix first, then the Python one, then restart the container. ===== 8. Disk space management ===== Each version's Docker image is 1.3–2.3GB; the filestore/data directories add up too. Once a stage is complete and verified: * The **container and image** for the now-obsolete version can be removed (''docker rm'', ''docker rmi'') — the migrated database itself is untouched, since it lives in the shared Postgres volume, not in that container. * Comment out the corresponding block in ''docker-compose.yaml'' (keep it for reference/possible rebuild later). * Unused base images pulled just to build a custom image (e.g. a bare ''odoo:19.0'' tag kept around after ''oca_openupgrade-odoo19'' was already built from it) can usually be removed too — Docker's layer store keeps the layers the built image actually uses. * The old version's **data directories** (filestore, addons) can be removed too for extra space, but this is a judgment call since it's not purely disposable the way the container/image is — confirm with whoever owns the migration before deleting. Rough numbers from this migration: removing the ''odoo11''/''odoo12''/''odoo13'' containers and images freed ~3.8GB; removing a couple of unused base image tags freed another ~2.5GB. ===== 9. Gotchas worth remembering ===== * **''local var'' under ''set -u''**: in bash, declaring ''local a b c'' and then only conditionally assigning ''b''/''c'' in one branch — if that branch isn't taken, referencing ''$b'' later throws "unbound variable" under ''set -u'', even though ''local'' alone is often assumed to initialize to empty string. Always give ''local'' declarations an explicit ''=""'' if any branch might skip assigning them. * **A "WITH ... UPDATE" CTE only scopes to that one statement.** Two separate ''UPDATE'' statements in the same script each need their **own** ''WITH'' clause if they both need a CTE — a CTE defined for the first doesn't carry over to the second. * **Bind-mount writes from the host don't always propagate cleanly into the container** on this host (editor rename-based saves in particular). Use ''docker exec -i <container> sh -c 'cat > path' < hostfile'' instead of a host-side write when patching a container source file. * **Odoo commits per-module as it goes** during ''-u all'' — a crash late in the run does **not** roll back schema/data changes from modules that already finished. This is why retries resume mid-way rather than replaying everything, and why a fix applied mid-run (e.g. dropping a stale column) sticks even if a *later* module then fails again. * A failed attempt's log **does** roll back the specific transaction that was open at the point of failure — so a newly-added column from that same failing statement won't be visible for inspection afterward, even though earlier, already-committed schema changes will be. ---- --- //[[nadirhabib96@gmail.com|Nadir Habib]] 2026/08/29 20:17// elosys/database_migration.txt Last modified: 2026/08/29 20:19by nadir