Skip to content

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

Description

@reichie020212

Summary

spp.hide.menu.menu_id carries no uniqueness constraint, and IrModuleModule.hide_menus() reads .state off the result of a search() that can legitimately return more than one row. A second row for the same menu therefore raises ValueError: Expected singleton inside _register_hook, which aborts the registry load — every request returns 500 and the instance becomes completely unreachable.

This is not hypothetical: it took down a DSWD 4Ps development instance today, and the only recovery was hand-editing the database.

The failing code

spp_hide_menus_base/models/ir_module_module.py:

hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)])
if not hidden_menus:
    hidden_menu = self.env["spp.hide.menu"].create({...})
    hidden_menu.hide_menu()
elif hidden_menus.state == "show":     # <-- ensure_one() on a 2-record set
    hidden_menus.hide_menu()
elif hidden_menus.state == "hide":
    hidden_menus._reapply_hide()

hide_menus() is called from _register_hook(), which runs at the end of every registry load.

Why a duplicate row appears at all

hide_menus() creates a row for any menu in MENU_APP that does not have one. So every database that has ever booted already carries rows for all 15 mapped menus (mail.menu_root_discuss, calendar.mail_menu_calendar, etc.), created in Python and owned by no module.

A downstream module that seeds an spp.hide.menu record for one of those menus in an XML data file cannot adopt the existing row — its <record> has its own xml_id and no ir.model.data pointing at the Python-created row, so it inserts a second row. Nothing rejects it.

The failure mode is nastily asymmetric:

  • Fresh install — the data file loads before the first _register_hook, so hide_menus() finds the seeded row and skips creating one. Exactly one row. Everything passes.
  • Existing database — the row already exists, the seed adds a second, and the next registry load bricks the instance.

So the mistake is invisible to CI and to any install-time test suite, and only manifests on deployment to environments that already have data.

Observed

File "/mnt/extra-addons/openspp/spp_hide_menus_base/models/ir_module_module.py", line 102, in _register_hook
    self.hide_menus()
File "/mnt/extra-addons/openspp/spp_hide_menus_base/models/ir_module_module.py", line 80, in hide_menus
    elif hidden_menus.state == "show":
File "/opt/odoo/odoo/odoo/orm/fields.py", line 1659, in __get__
    record.ensure_one()
ValueError: Expected singleton: spp.hide.menu(2, 23)
2026-08-11 08:58:35,404 1 ERROR ? odoo.registry: Failed to load registry

Database state at the time:

menu row ir.model.data origin
76 mail.menu_root_discuss 2 (none) hide_menus() auto-created
76 23 downstream seed duplicate
140 calendar.mail_menu_calendar 4 (none) hide_menus() auto-created
140 24 downstream seed duplicate

Reproduction

  1. Install spp_hide_menus_base and let the registry load once (creates the MENU_APP rows).
  2. Install any module whose data file contains an spp.hide.menu record referencing e.g. mail.menu_root_discuss.
  3. Restart. The registry fails to load; every request 500s.

A second, quieter bug in the same area

hide_menu() snapshots the menu's real groups into default_group_ids and collapses group_ids to the hide group. If a row is left in state show while its menu is already collapsed, the next hide_menus() takes the elif ... == "show" branch and re-snapshots default_group_ids from the collapsed group_ids — permanently overwriting the real groups with the hide group. show_menu() then restores nothing, and _reapply_hide() cannot repair it either (its real = group_ids - hide_group is empty, so it skips).

That state is exactly what clearing a duplicate leaves behind if the survivor's state is not reconciled, which makes it a natural consequence of this bug rather than a separate one.

Proposed fix

Two parts, because neither alone is sufficient:

  1. Make hide_menus() singleton-safe so no database can be taken down by a duplicate that already exists in the wild. This is the part that matters for instances currently deployed.
  2. Add models.Constraint("UNIQUE(menu_id)", ...) to spp.hide.menu, behind a pre-migrate that de-duplicates first, so the state stops recurring. The de-dup must keep the row whose default_group_ids is not merely the hide group — that is the only row that can still restore its menu.

The ordering works out: migrate_module(package, 'pre') (loading.py:174) runs before registry.init_models(...) (:194), where the constraint is applied, so the index lands on already-clean data.

I have a PR ready implementing both, and will link it here.

Impact

Any layer-2/3 module can trip this, and the consequence is a total outage rather than a degraded feature. The downstream fix (dropping the colliding seed) protects one repo; the constraint protects everyone.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions