fix(spp_hide_menus_base): a duplicate menu_id row must not abort the registry load - #409
Open
reichie020212 wants to merge 1 commit into
Open
fix(spp_hide_menus_base): a duplicate menu_id row must not abort the registry load#409reichie020212 wants to merge 1 commit into
reichie020212 wants to merge 1 commit into
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.52% +1.03%
==========================================
Files 243 376 +133
Lines 20785 27203 +6418
==========================================
+ Hits 14860 19729 +4869
- Misses 5925 7474 +1549
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.