fix(spp_hide_menus_base): a duplicate menu_id row must not abort the registry load - #409
Conversation
…registry load
hide_menus() reads .state off the result of search([("menu_id", "=", menu.id)]).
It runs from _register_hook, i.e. on every registry load, so a second row for one
menu raises ValueError: Expected singleton there and the registry never loads —
every request returns 500 and the instance is unreachable until the extra row is
deleted by hand. Closes #408.
A duplicate is easy to create and nothing rejected it. hide_menus() itself creates
a row for every MENU_APP menu that lacks one, so those rows exist on any database
that has ever booted; a downstream module seeding its own spp.hide.menu record for
one of them cannot adopt the existing row (its <record> carries a new xml_id) and
inserts a second. The failure is asymmetric in the worst way: on a fresh install
the data file loads before the first _register_hook, so exactly one row exists and
everything passes. Only databases with prior data break, which puts the failure
past CI and into deployment.
Both halves are needed. UNIQUE(menu_id) makes the state unrepresentable, but
Registry.post_constraint catches any failure from applying a constraint and only
logs it, so a database that still holds duplicates when this lands keeps them AND
keeps running — unconstrained, and still crashing. Reading state off _primary()
rather than off the search result is what protects those.
Which row survives is not arbitrary. hide_menu() snapshots group_ids into
default_group_ids, so a row created after the menu was already collapsed holds
nothing but the hide group and show_menu() on it restores a menu nobody can see.
_primary() and the de-dup migration both prefer a row that can still restore its
menu, lowest id breaking the tie. An empty snapshot is not degraded: a menu
declaring no groups is correctly restored to no groups.
The migration is pre-migrate deliberately — migrate_module(package, 'pre') precedes
registry.init_models(), where the constraint is applied, so the index lands on data
that already satisfies it.
Signed-off-by: Red <redick@newlogic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 19.0 #409 +/- ##
==========================================
+ Coverage 71.49% 72.54% +1.05%
==========================================
Files 243 376 +133
Lines 20785 27201 +6416
==========================================
+ Hits 14860 19733 +4873
- Misses 5925 7468 +1543
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
gonzalesedwin1123
left a comment
There was a problem hiding this comment.
The root-cause analysis and the two-halves design both check out, and the test quality is genuinely good (the negative control in particular). One gap needs to be addressed before merging:
The migration leaves dangling ir.model.data behind, which turns into a failed upgrade later
migrations/19.0.2.1.0/pre-migrate.py deletes the surplus spp_hide_menu rows with raw SQL, but the downstream seed's ir.model.data entry still points at the deleted id. On the next -u of that downstream module, Odoo sees the xml_id's record is missing and recreates it (the seed is noupdate="0"), which inserts a new row for the same menu_id — now rejected by UNIQUE(menu_id). So the downstream module's upgrade fails with an IntegrityError.
The databases that will hit this are exactly the ones this PR is rescuing: any DB that held duplicates (including the DSWD 4Ps instance from #408) will de-dup cleanly on upgrade, then fail the next upgrade of the downstream module that seeded the duplicate. A loud failure pointing at the offending module is much better than the current bricked instance, but we shouldn't ship a migration that schedules a second incident.
Requested change
After collecting surplus_ids, repoint their ir.model.data entries to the surviving row instead of leaving them dangling, e.g.:
UPDATE ir_model_data d
SET res_id = <survivor_id_for_that_menu>
WHERE d.model = 'spp.hide.menu'
AND d.res_id IN <surplus_ids>;(one survivor per menu_id, so this can be driven off the same ranked CTE — rank 1 is the survivor, rank > 1 are the surplus rows). With the xml_id adopted by the survivor, the downstream module's next data load updates the existing row rather than inserting a duplicate, and no upgrade breaks.
Two things to be aware of with the repoint (both acceptable, worth a comment in the migration):
- If two seeds pointed at the same menu, both xml_ids end up on one row; that's the correct end state under the constraint.
- The downstream data file will now write its field values onto the survivor on each upgrade (e.g.
state). If it writesstate = "show"while the menu is already collapsed,hide_menu()will re-snapshot the collapsedgroup_idsintodefault_group_ids— the quieter degradation already described in #408. That's a pre-existing issue and out of scope here, but the repoint makes the path slightly more reachable, so it's worth noting.
Separately from the migration: the downstream seed itself should still be dropped (as #408 already says) — the repoint just guarantees the ordering doesn't matter.
|
Addendum to my changes-requested review, after re-checking my own requested fix against the Odoo 19 source. Three corrections, the first one substantive: 1. The repoint alone is incomplete — the repointed
|
…ing them Addresses the review on #409. Deleting the surplus spp.hide.menu rows left the downstream seed's ir.model.data entries pointing at ids that no longer exist, which is not inert: on that module's next -u, _load_records finds the record missing, unlinks the stale imd row and re-creates the record (odoo/orm/models.py, the else-branch at ~5166) — a second row for the same menu_id, now rejected by UNIQUE(menu_id). So the migration traded a bricked instance for a failed upgrade one release later. The surplus rows' xml_ids are now repointed onto the survivor and marked noupdate. Both halves were checked against the Odoo 19 source rather than inferred: - The recreate branch does NOT consult noupdate, so a noupdate="1" seed would dangle and recreate just the same. Repointing is what stops it; the flag is not load-bearing for that half. - noupdate IS load-bearing for the end state #408 asks for. _process_end (addons/base/models/ir_model.py) garbage-collects a module's xml_ids that were not loaded during the upgrade AND unlinks the records they point to, selecting on COALESCE(noupdate, false) != true. Without the flag, dropping the seed later deletes the survivor — and since _process_end runs before _register_hook in the same upgrade, hide_menus() then recreates a row for the still-collapsed menu and snapshots the hide group into default_group_ids. Silent data loss instead of a loud failure. The flag also stops the seed writing its field values onto the survivor on every upgrade, which is the state = "show" re-snapshot path the review flagged as made slightly more reachable by the repoint. It is now unreachable through this xml_id. The repoint runs BEFORE the DELETE and reuses the same ranked CTE as the surplus lookup, extracted to a module constant so the two cannot disagree about which row is the survivor. Running it after the delete would match nothing. Six tests drive the migration directly through importlib (the pattern spp_gis/tests/test_migration_geofence_tags.py already uses) and assert on ir_model_data, because the failure is invisible from the spp.hide.menu side — the row count is right either way. Negative controls: dropping noupdate and removing the repoint entirely each fail exactly the three tests that assert them. Signed-off-by: Red <redick@newlogic.com>
|
Addressed in I checked both mechanisms against the Odoo 19 source rather than taking them from the review, and they hold:
Two implementation notes: The repoint runs before the DELETE. It is driven off the same ranked CTE, which re-reads
Six tests drive the migration directly ( Negative controls, since the assertions are on a column that is easy to get accidentally right: dropping
On your point 3 — agreed, "exactly the ones this PR is rescuing" was overstated. Duplicates created by two workers racing through One thing I could not run locally: |
…the de-dup A database upgraded from an old release can carry group_menu_visibility alongside group_hide_menus_user; a snapshot against either is equally unable to restore its menu, so the ranking now takes an array of every resolvable hide group. Also log each deleted row id/menu_id pair at warning (the only forensic trail if the wrong row is kept), let search_path resolve table names instead of hardcoding public, and move the nosec B608 markers onto the lines bandit actually attributes the finding to - on the cr.execute() line above they were never seen and CI failed.
_hide_group() used env.ref with the default raise_if_not_found=True, so the safety net could itself abort the registry load from _register_hook when neither group xml_id resolves. It now returns None instead; _primary() falls back to the lowest id and hide_menu()/_reapply_hide() warn and leave the menu alone. The two inline copies of the ref-with- fallback lookup collapse into _hide_group() so the rule lives in one place. Also strengthen the duplicate-tolerance test to assert the surviving row actually hid the menu (not just that nothing raised), and drop the aborted record from the ORM cache after the constraint-violation test.
Covers the constraint, the defensive read, the de-dup migration with the xml_id repoint, and the new loud failure mode for downstream modules seeding an already-configured menu. README regeneration is left to CI's pinned generator per the repo convention.
Applied verbatim from the pre-commit job's --show-diff-on-failure output; the local generator renders RST table widths differently.
Fixes #408.
The bug
hide_menus()reads.stateoff the result of asearch()that can return more than one row:It is called from
_register_hook(), which runs at the end of every registry load. So a second row for one menu raisesValueError: Expected singletonthere, the registry never loads, and every request returns 500. The instance is unreachable until someone deletes the extra row directly in the database. This took down a DSWD 4Ps instance; the traceback and the observed rows are in #408.Why a duplicate is easy to create
hide_menus()itself creates a row for everyMENU_APPmenu that lacks one, so those rows exist on any database that has ever booted, created in Python and owned by no module. A downstream module seeding its ownspp.hide.menurecord for one of those menus cannot adopt the existing row — its<record>carries a new xml_id and noir.model.datapoints at the Python-created one — so it inserts a second.The failure is asymmetric in the worst possible way:
_register_hook, sohide_menus()finds the seeded row and skips creating. One row. Everything passes.So it is invisible to CI and to any install-time suite, and only appears on deployment to environments that already have data.
The fix, in two halves
Neither half is sufficient alone, which is the main thing I would ask reviewers to check.
UNIQUE(menu_id)onspp.hide.menumakes the state unrepresentable going forward.hide_menus()reads.stateoff_primary(), never off the search result.The second is not belt-and-braces.
Registry.post_constraint(odoo/orm/registry.py) catches any exception from applying a constraint and only logs it —_schema.erroron install,_schema.infoon upgrade:So a database that still holds duplicates when this lands keeps them and keeps running, unconstrained. Precisely the databases that crash are the ones that would end up without the constraint. The defensive read is what protects them.
Which row survives is not arbitrary
hide_menu()snapshots the menu'sgroup_idsintodefault_group_idsand collapsesgroup_idsto the hide group. A row created after the menu was already collapsed therefore holds nothing but the hide group, andshow_menu()on it restores a menu nobody can see._primary()and the de-dup migration both apply the same rule: prefer a row that can still restore its menu, lowest id breaking the tie. An empty snapshot is not degraded — a menu declaring no groups is correctly restored to no groups.Migration ordering
migrations/19.0.2.1.0/pre-migrate.pyis pre-migrate deliberately:migrate_module(package, 'pre')(odoo/modules/loading.py:174) precedesregistry.init_models(...)(:194), where the constraint is applied. The index therefore lands on data that already satisfies it. Post-migrate would be too late — and would fail quietly, per thepost_constraintbehaviour above.Tests
Three added to
tests/test_hide_menu.py; nothing removed or weakened.test_a_menu_cannot_have_two_hide_configurations— the constraint rejects the state.test_primary_prefers_a_row_that_can_still_restore_its_menu— the selection rule, including that an empty snapshot is valid.test_hide_menus_tolerates_a_duplicate_the_constraint_could_not_block— the defensive read.That last one drops the constraint inside the test transaction before inserting the duplicate. That is not a contrivance: it is exactly the database state
post_constraintleaves behind when it swallows a failed constraint, and it is the only way to construct one. DDL is transactional in PostgreSQL, so the constraint returns on rollback.Verification
-i spp_hide_menus_base --test-tags /spp_hide_menus_base: 16 tests, 0 failed, 0 errors._primary()call inhide_menus()and re-running gives1 error(s) of 16 tests— exactlytest_hide_menus_tolerates_a_duplicate_the_constraint_could_not_block, failing withValueError: Expected singleton: spp.hide.menu(9, 10), the same error class as the production outage. So the test measures the fix rather than passing incidentally.Two disclosures
I ran
pre-commitwithSKIP=oca-gen-addon-readme,bandit. Neither hook was evaluating this change, and I would rather say so than have it found later:oca-gen-addon-readmeregenerates every module's README on any run, regardless of what is staged. On a fresh clone of19.0it rewrote ~6,600 lines across 90 untouched modules, which aborts the commit and would have buried a 5-file fix in a 171-file diff. That looks like drift between the committed READMEs and what the pinned hook version now generates — worth a separate look, but not something this PR should carry.banditexits 2 withpyproject.toml : toml parser not available, reinstall with toml extra. I confirmed it fails identically on untouched files (spp_registry/models/registrant.py,spp_area/models/area.py), so it is a broken hook environment rather than a finding.Every other hook passes on the changed files, including
ruff,ruff-format,pylint_odoo,oca-checks-odoo-moduleand theopenspp-*checks.Left alone deliberately
hide_menu()and_reapply_hide()each carry their own copy of the hide-grouptry/exceptthat I factored into_hide_group(). Folding them into it is an obvious cleanup, but it is unrelated to this bug, so the new helper is used only on the new path. Happy to include it if you would prefer it in one go.