Skip to content

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
19.0from
fix/408-hide-menu-duplicate-aborts-registry
Open

fix(spp_hide_menus_base): a duplicate menu_id row must not abort the registry load#409
reichie020212 wants to merge 1 commit into
19.0from
fix/408-hide-menu-duplicate-aborts-registry

Conversation

@reichie020212

Copy link
Copy Markdown
Member

Fixes #408.

The bug

hide_menus() reads .state off the result of a search() that can return more than one row:

hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)])
...
elif hidden_menus.state == "show":     # ensure_one() on a 2-record set

It is called from _register_hook(), which runs at the end of every registry load. So a second row for one menu raises ValueError: Expected singleton there, 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 every MENU_APP menu 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 own spp.hide.menu record for one of those menus cannot adopt the existing row — its <record> carries a new xml_id and no ir.model.data points at the Python-created one — so it inserts a second.

The failure is asymmetric in the worst possible way:

result
Fresh install data file loads before the first _register_hook, so hide_menus() finds the seeded row and skips creating. One row. Everything passes.
Existing database the row is already there, the seed adds a second, next registry load bricks the instance.

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.

  1. UNIQUE(menu_id) on spp.hide.menu makes the state unrepresentable going forward.
  2. hide_menus() reads .state off _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.error on install, _schema.info on upgrade:

except Exception as e:
    if self._is_install:
        _schema.error(*e.args)
    else:
        _schema.info(*e.args)

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's group_ids into default_group_ids and collapses group_ids to the hide group. A row created after the menu was already collapsed therefore holds nothing but the hide group, and show_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.py is pre-migrate deliberately: migrate_module(package, 'pre') (odoo/modules/loading.py:174) precedes registry.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 the post_constraint behaviour 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_constraint leaves 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.
  • Negative control: reverting only the _primary() call in hide_menus() and re-running gives 1 error(s) of 16 tests — exactly test_hide_menus_tolerates_a_duplicate_the_constraint_could_not_block, failing with ValueError: 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-commit with SKIP=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-readme regenerates every module's README on any run, regardless of what is staged. On a fresh clone of 19.0 it 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.
  • bandit exits 2 with pyproject.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-module and the openspp-* checks.

Left alone deliberately

hide_menu() and _reapply_hide() each carry their own copy of the hide-group try/except that 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.

…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

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.52%. Comparing base (c33d3cb) to head (ea2645d).

Files with missing lines Patch % Lines
spp_hide_menus_base/models/hide_menu.py 83.33% 2 Missing ⚠️
spp_hide_menus_base/models/ir_module_module.py 83.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
spp_analytics 93.25% <ø> (?)
spp_api_v2_change_request 66.53% <ø> (ø)
spp_api_v2_cycles 71.03% <ø> (?)
spp_api_v2_data 77.77% <ø> (?)
spp_api_v2_entitlements 70.23% <ø> (?)
spp_api_v2_gis 71.57% <ø> (?)
spp_api_v2_programs 92.22% <ø> (?)
spp_approval 50.34% <ø> (?)
spp_area 80.16% <ø> (?)
spp_area_hdx 81.60% <ø> (?)
spp_base_common 91.07% <ø> (ø)
spp_case_cel 89.50% <ø> (?)
spp_case_demo 94.75% <ø> (?)
spp_case_entitlements 100.00% <ø> (?)
spp_case_programs 100.00% <ø> (?)
spp_hide_menus_base 88.50% <83.33%> (?)
spp_programs 65.27% <ø> (ø)
spp_registry 87.22% <ø> (+0.07%) ⬆️
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_hide_menus_base/models/ir_module_module.py 90.90% <83.33%> (ø)
spp_hide_menus_base/models/hide_menu.py 86.27% <83.33%> (ø)

... and 132 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spp.hide.menu: a duplicate menu_id row aborts the registry load (ValueError: Expected singleton in _register_hook)

1 participant