diff --git a/odoo_repository/models/__init__.py b/odoo_repository/models/__init__.py index 6c8e744d..b428a870 100644 --- a/odoo_repository/models/__init__.py +++ b/odoo_repository/models/__init__.py @@ -1,5 +1,6 @@ from . import authentication_token from . import ssh_key +from . import odoo_ref_data_mixin from . import odoo_author from . import odoo_branch from . import odoo_license diff --git a/odoo_repository/models/odoo_module_branch.py b/odoo_repository/models/odoo_module_branch.py index 2229d0f4..3d68de71 100644 --- a/odoo_repository/models/odoo_module_branch.py +++ b/odoo_repository/models/odoo_module_branch.py @@ -18,6 +18,7 @@ class OdooModuleBranch(models.Model): _name = "odoo.module.branch" + _inherit = "odoo.ref.data.mixin" _description = "Odoo Module Branch" _order = "repository_sequence, module_name, branch_name" @@ -644,13 +645,13 @@ def _get_module_category_id(self, category_name): rec = self.env["odoo.module.category"].search( [("name", "=", category_name)], limit=1 ) - if not rec: - rec = ( - self.env["odoo.module.category"] - .sudo() - .create({"name": category_name}) - ) - return rec.id + if rec: + return rec.id + return self._create_ref_data( + "odoo.module.category", + [("name", "=", category_name)], + {"name": category_name}, + ) return False @tools.ormcache("names") @@ -661,14 +662,14 @@ def _get_author_ids(self, names): names = [name.strip() for name in names.split(",")] authors = self.env["odoo.author"].search([("name", "in", names)]) missing_author_names = set(names) - set(authors.mapped("name")) - missing_authors = self.env["odoo.author"] + created_ids = [] if missing_author_names: - missing_authors = ( - self.env["odoo.author"] - .sudo() - .create([{"name": name} for name in missing_author_names]) + created_ids = self._create_ref_data_multi( + "odoo.author", + "name", + [{"name": name} for name in missing_author_names], ) - return (authors | missing_authors).ids + return authors.ids + created_ids return [] @tools.ormcache("names") @@ -676,12 +677,14 @@ def _get_maintainer_ids(self, names): if names: maintainers = self.env["odoo.maintainer"].search([("name", "in", names)]) missing_maintainer_names = set(names) - set(maintainers.mapped("name")) - created = self.env["odoo.maintainer"] + created_ids = [] if missing_maintainer_names: - created = created.sudo().create( - [{"name": name} for name in missing_maintainer_names] + created_ids = self._create_ref_data_multi( + "odoo.maintainer", + "name", + [{"name": name} for name in missing_maintainer_names], ) - return (maintainers | created).ids + return maintainers.ids + created_ids return [] @tools.ormcache("name") @@ -690,9 +693,11 @@ def _get_dev_status_id(self, name): rec = self.env["odoo.module.dev.status"].search( [("name", "=", name)], limit=1 ) - if not rec: - rec = self.env["odoo.module.dev.status"].sudo().create({"name": name}) - return rec.id + if rec: + return rec.id + return self._create_ref_data( + "odoo.module.dev.status", [("name", "=", name)], {"name": name} + ) return False @api.model @@ -759,13 +764,15 @@ def _get_python_dependency_ids(self, packages): dependencies = self.env["odoo.python.dependency"].search( [("name", "in", packages)] ) - missing_dependencies = set(packages) - set(dependencies.mapped("name")) - created = self.env["odoo.python.dependency"] - if missing_dependencies: - created = created.sudo().create( - [{"name": package} for package in missing_dependencies] + missing_names = set(packages) - set(dependencies.mapped("name")) + created_ids = [] + if missing_names: + created_ids = self._create_ref_data_multi( + "odoo.python.dependency", + "name", + [{"name": name} for name in missing_names], ) - return (dependencies | created).ids + return dependencies.ids + created_ids return [] @tools.ormcache("license_name") @@ -773,9 +780,11 @@ def _get_license_id(self, license_name): if license_name: license_model = self.env["odoo.license"] rec = license_model.search([("name", "=", license_name)], limit=1) - if not rec: - rec = license_model.sudo().create({"name": license_name}) - return rec.id + if rec: + return rec.id + return self._create_ref_data( + "odoo.license", [("name", "=", license_name)], {"name": license_name} + ) return False def _get_module(self, name): diff --git a/odoo_repository/models/odoo_ref_data_mixin.py b/odoo_repository/models/odoo_ref_data_mixin.py new file mode 100644 index 00000000..9b4262e2 --- /dev/null +++ b/odoo_repository/models/odoo_ref_data_mixin.py @@ -0,0 +1,84 @@ +# Copyright 2026 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import threading +from contextlib import contextmanager + +import psycopg2.errors + +from odoo import api, models + + +class OdooRefDataMixin(models.AbstractModel): + """Shared helpers to safely create reference data cached by ``@tools.ormcache``.""" + + _name = "odoo.ref.data.mixin" + _description = "Odoo Ref Data Mixin" + + @contextmanager + def _ref_data_cursor(self): + """Yield the cursor to use for an isolated reference-data creation. + + This method is a context manager that yields a cursor to use for creating + reference data in a separate transaction. If the current environment is in + test mode, it yields the current cursor instead, since in test mode we + don't want to create a new transaction. + """ + in_test_mode = self.env.registry.in_test_mode() or getattr( + threading.current_thread(), "testing", False + ) + if in_test_mode: + yield self.env.cr + else: + with self.pool.cursor() as new_cr: + yield new_cr + + def _create_ref_data(self, model_name, domain, values): + """Create a single get-or-create reference record in its own + dedicated, immediately-committed transaction, and return its id. + + ``@tools.ormcache`` is a process-wide cache that is never invalidated + by a SQL ROLLBACK. If a newly created record's id were cached from + within the caller's (still uncommitted) transaction, and that + transaction was later rolled back for an unrelated reason (e.g. a + conflicting concurrent queue job), the cache would keep returning an + id that no longer exists in the database. Creating and committing the + record here, independently of the caller's transaction, guarantees + the returned id always stays valid, whatever happens to the caller's + job afterwards. + + A concurrent job may create the same record at the same time: the + unique SQL constraint on the target model then raises + ``UniqueViolation`` for the loser, which simply looks up the row the + winner just committed. + """ + with self._ref_data_cursor() as new_cr: + env = api.Environment(new_cr, self.env.uid, self.env.context) + model = env[model_name].sudo() + try: + with new_cr.savepoint(): + return model.create(values).id + except psycopg2.errors.UniqueViolation: + return model.search(domain, limit=1).id + + def _create_ref_data_multi(self, model_name, search_field, values_list): + """Same as ``_create_ref_data``, but for a batch of records sharing + the same natural-key field (e.g. several new authors discovered at + once). Each record is created individually, in its own savepoint, so + that a conflict on one of them does not abort the creation of the + others. + """ + ids = [] + with self._ref_data_cursor() as new_cr: + env = api.Environment(new_cr, self.env.uid, self.env.context) + model = env[model_name].sudo() + for values in values_list: + try: + with new_cr.savepoint(): + ids.append(model.create(values).id) + except psycopg2.errors.UniqueViolation: + existing = model.search( + [(search_field, "=", values[search_field])], limit=1 + ) + ids.append(existing.id) + return ids diff --git a/odoo_repository/models/odoo_repository.py b/odoo_repository/models/odoo_repository.py index 71bbeba1..2118db3e 100644 --- a/odoo_repository/models/odoo_repository.py +++ b/odoo_repository/models/odoo_repository.py @@ -25,6 +25,7 @@ class OdooRepository(models.Model): _name = "odoo.repository" + _inherit = "odoo.ref.data.mixin" _description = "Odoo Modules Repository" _order = "sequence, display_name" @@ -501,12 +502,18 @@ def _import_data(self, data): def _prepare_module_branch_values(self, data): # Get branch, repository and technical module branch = self.env["odoo.branch"].search([("name", "=", data["branch"])]) - org = self._get_repository_org(data["repository"]["org"]) - repository = self._get_repository( - org.id, data["repository"]["name"], data["repository"] + org_id = self._get_repository_org_id(data["repository"]["org"]) + repository_id = self._get_repository_id( + org_id, data["repository"]["name"], data["repository"] ) - repository_branch = self._get_repository_branch( - org.id, repository.id, branch.id, data["repository"] + repository_branch_id = self._get_repository_branch_id( + org_id, repository_id, branch.id, data["repository"] + ) + # `_get_repository_branch_id` returns a plain id (see `_create_ref_data` + # docstring), so browse it here to get a recordset bound to this + # transaction's cursor. + repository_branch = self.env["odoo.repository.branch"].browse( + repository_branch_id ) mb_model = self.env["odoo.module.branch"] @@ -605,21 +612,18 @@ def _post_create_or_update_module_branch(self, rec, values, raw_data): """Hook executed after the creation or update of `rec`.""" @tools.ormcache("name") - def _get_repository_org(self, name): + def _get_repository_org_id(self, name): rec = self.env["odoo.repository.org"].search([("name", "=", name)], limit=1) - if not rec: - rec = self.env["odoo.repository.org"].sudo().create({"name": name}) - return rec + if rec: + return rec.id + return self._create_ref_data( + "odoo.repository.org", [("name", "=", name)], {"name": name} + ) @tools.ormcache("org_id", "name") - def _get_repository(self, org_id, name, data): - rec = self.env["odoo.repository"].search( - [ - ("org_id", "=", org_id), - ("name", "=", name), - ], - limit=1, - ) + def _get_repository_id(self, org_id, name, data): + domain = [("org_id", "=", org_id), ("name", "=", name)] + rec = self.env["odoo.repository"].search(domain, limit=1) values = { "org_id": org_id, "name": name, @@ -629,19 +633,16 @@ def _get_repository(self, org_id, name, data): } if rec: rec.sudo().write(values) - else: - rec = self.env["odoo.repository"].sudo().create(values) - return rec + return rec.id + return self._create_ref_data("odoo.repository", domain, values) @tools.ormcache("org_id", "repository_id", "branch_id") - def _get_repository_branch(self, org_id, repository_id, branch_id, data): - rec = self.env["odoo.repository.branch"].search( - [ - ("repository_id", "=", repository_id), - ("branch_id", "=", branch_id), - ], - limit=1, - ) + def _get_repository_branch_id(self, org_id, repository_id, branch_id, data): + domain = [ + ("repository_id", "=", repository_id), + ("branch_id", "=", branch_id), + ] + rec = self.env["odoo.repository.branch"].search(domain, limit=1) values = { "repository_id": repository_id, "branch_id": branch_id, @@ -649,9 +650,8 @@ def _get_repository_branch(self, org_id, repository_id, branch_id, data): } if rec: rec.sudo().write(values) - else: - rec = self.env["odoo.repository.branch"].sudo().create(values) - return rec + return rec.id + return self._create_ref_data("odoo.repository.branch", domain, values) def _get_resource_url(self, branch, path): self.ensure_one() diff --git a/odoo_repository/readme/CONTRIBUTORS.md b/odoo_repository/readme/CONTRIBUTORS.md index 17752927..e6aa7475 100644 --- a/odoo_repository/readme/CONTRIBUTORS.md +++ b/odoo_repository/readme/CONTRIBUTORS.md @@ -1,2 +1,4 @@ - Camptocamp - Sébastien Alix \ +- ACSONE SA/NV + - Laurent Mignon \