diff --git a/base_field_trigger_warmup/README.rst b/base_field_trigger_warmup/README.rst new file mode 100644 index 00000000000..811936c14a3 --- /dev/null +++ b/base_field_trigger_warmup/README.rst @@ -0,0 +1,147 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================= +Field Trigger Tree Warmup +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c32021bf286316f53f09e2a7ef5dce116ebfce5f52d8584ad1d093a4ca6a9b76 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/16.0/base_field_trigger_warmup + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_field_trigger_warmup + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +The ORM resolves the transitive closure of compute triggers lazily: the first +time a field is written, Odoo walks its ``@api.depends`` graph and caches the +resulting trigger tree in the registry. On models with many interdependent +stored computed fields, that first write pays for the whole closure, so the +first request served by each worker is noticeably slower than the ones that +follow, and every restart brings the penalty back. + +This module builds those trees while the registry loads, where no user is +waiting. It has no user interface and no effect on behaviour: it only decides +*when* work the ORM would do anyway is done. + +It was extracted from a production deployment whose invoice line model carries +around 180 interdependent stored fields: the first write on a fresh worker cost +about five times what every following write cost. Warming up the trees at boot +removed the difference. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +By default every model in the registry is warmed up. On a large database that +costs a few seconds of boot time per worker, which is usually a good trade, +but it can be narrowed down. + +To warm up only the models that matter, set the system parameter +``base_field_trigger_warmup.models`` to a comma separated list of model names:: + + base_field_trigger_warmup.models = account.move,account.move.line + +Model names that do not exist in the registry are ignored with a warning. +Setting the parameter to ``*`` or leaving it empty restores the default of +warming up everything. + +To disable the warmup entirely without uninstalling the module, for instance on +a development machine or in a CI pipeline where boot time matters more than the +first request, export:: + + ODOO_FIELD_TRIGGER_WARMUP=0 + +The warmup is always skipped while tests are running. + +Usage +===== + +There is nothing to do: installing the module is enough. + +At the end of each registry load the module logs, at INFO level, how many +trigger trees it built and how long it took:: + + INFO odoo.addons.base_field_trigger_warmup: Warmed up 24868 field + trigger trees in 6.52s + +Use that line to decide whether the default scope is worth its boot cost, and +narrow it down with the system parameter described in the configuration section +if it is not. + +Known issues / Roadmap +====================== + +The module relies on ``Registry.get_field_trigger_tree``, which is private ORM +API. It is called defensively: if a future Odoo version renames or removes it, +the module logs the fact and does nothing instead of breaking the registry +load. Porting to a new version should start by checking that method. + +Warming up the trees hides the symptom of a wide compute graph. When the first +request of a worker is slow enough to need this module, it is also worth +looking at whether the graph itself can be reduced. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* KMEE + +Contributors +~~~~~~~~~~~~ + +* `KMEE `_: + + * Luis Felipe Mileo + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_field_trigger_warmup/__init__.py b/base_field_trigger_warmup/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/base_field_trigger_warmup/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_field_trigger_warmup/__manifest__.py b/base_field_trigger_warmup/__manifest__.py new file mode 100644 index 00000000000..2de4361d6ec --- /dev/null +++ b/base_field_trigger_warmup/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 KMEE INFORMATICA LTDA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Field Trigger Tree Warmup", + "summary": "Build the compute dependency trees at boot instead of on the " + "first request", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Tools", + "author": "KMEE, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "license": "AGPL-3", + "depends": ["base"], + "installable": True, +} diff --git a/base_field_trigger_warmup/models/__init__.py b/base_field_trigger_warmup/models/__init__.py new file mode 100644 index 00000000000..f582159f032 --- /dev/null +++ b/base_field_trigger_warmup/models/__init__.py @@ -0,0 +1 @@ +from . import base_field_trigger_warmup diff --git a/base_field_trigger_warmup/models/base_field_trigger_warmup.py b/base_field_trigger_warmup/models/base_field_trigger_warmup.py new file mode 100644 index 00000000000..f6d499d467b --- /dev/null +++ b/base_field_trigger_warmup/models/base_field_trigger_warmup.py @@ -0,0 +1,120 @@ +# Copyright 2026 KMEE INFORMATICA LTDA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import os +import time + +from odoo import api, models + +_logger = logging.getLogger(__name__) + +PARAM_MODELS = "base_field_trigger_warmup.models" +ENV_DISABLE = "ODOO_FIELD_TRIGGER_WARMUP" + + +class BaseFieldTriggerWarmup(models.AbstractModel): + """Build the ORM compute dependency trees while the registry loads. + + The ORM resolves the transitive closure of compute triggers lazily, the + first time a field is written. On models with many interdependent stored + computed fields, that first write pays for the whole closure, so the first + request served by each worker is much slower than the following ones. This + model moves that cost to the registry load, where no user is waiting. + """ + + _name = "base.field.trigger.warmup" + _description = "Field Trigger Tree Warmup" + + @api.model + def _warmup_is_enabled(self): + """Warmup is skipped in tests and when the env var is set to 0.""" + if os.environ.get(ENV_DISABLE, "1") == "0": + return False + return not self.env.registry.in_test_mode() + + @api.model + def _warmup_model_names(self): + """Model names to warm up. + + Read from the ``base_field_trigger_warmup.models`` system parameter: a + comma separated list of model names, or ``*`` (the default) for every + model in the registry. Narrowing it down is useful when only a few + models are expensive and boot time matters. + """ + param = ( + self.env["ir.config_parameter"].sudo().get_param(PARAM_MODELS, default="*") + or "" + ).strip() + if param in ("", "*"): + return list(self.env.registry) + wanted = [name.strip() for name in param.split(",") if name.strip()] + known, unknown = [], [] + for name in wanted: + (known if name in self.env.registry else unknown).append(name) + if unknown: + _logger.warning( + "%s lists unknown models, ignored: %s", + PARAM_MODELS, + ", ".join(unknown), + ) + return known + + @api.model + def _warmup_field_trigger_trees(self, model_names=None): + """Build the trigger tree of every field of ``model_names``. + + Returns the number of fields whose tree was built. Failures on a single + field are logged at debug level and skipped: a warmup must never be able + to break the boot. + """ + registry = self.env.registry + # Private ORM API: guard it so an Odoo version that renames or drops it + # degrades to a no-op instead of breaking the registry load. + build_tree = getattr(registry, "get_field_trigger_tree", None) + if build_tree is None: + _logger.info( + "This Odoo build has no Registry.get_field_trigger_tree, " + "nothing to warm up" + ) + return 0 + if model_names is None: + model_names = self._warmup_model_names() + count = 0 + for model_name in model_names: + model = self.env.get(model_name) + if model is None: + continue + for field in model._fields.values(): + try: + build_tree(field) + except Exception: # pylint: disable=except-pass + _logger.debug( + "Could not build the trigger tree of %s.%s", + model_name, + field.name, + exc_info=True, + ) + continue + count += 1 + return count + + def _register_hook(self): + res = super()._register_hook() + registry = self.env.registry + # _register_hook runs once per model that defines it, and the registry + # may be loaded more than once per process. + if getattr(registry, "_field_trigger_warmup_done", False): + return res + registry._field_trigger_warmup_done = True + if not self._warmup_is_enabled(): + return res + start = time.time() + count = self._warmup_field_trigger_trees() + if count: + _logger.info( + "Warmed up %s field trigger trees in %.2fs", + count, + time.time() - start, + ) + return res diff --git a/base_field_trigger_warmup/readme/CONFIGURE.rst b/base_field_trigger_warmup/readme/CONFIGURE.rst new file mode 100644 index 00000000000..ce168823e88 --- /dev/null +++ b/base_field_trigger_warmup/readme/CONFIGURE.rst @@ -0,0 +1,20 @@ +By default every model in the registry is warmed up. On a large database that +costs a few seconds of boot time per worker, which is usually a good trade, +but it can be narrowed down. + +To warm up only the models that matter, set the system parameter +``base_field_trigger_warmup.models`` to a comma separated list of model names:: + + base_field_trigger_warmup.models = account.move,account.move.line + +Model names that do not exist in the registry are ignored with a warning. +Setting the parameter to ``*`` or leaving it empty restores the default of +warming up everything. + +To disable the warmup entirely without uninstalling the module, for instance on +a development machine or in a CI pipeline where boot time matters more than the +first request, export:: + + ODOO_FIELD_TRIGGER_WARMUP=0 + +The warmup is always skipped while tests are running. diff --git a/base_field_trigger_warmup/readme/CONTRIBUTORS.rst b/base_field_trigger_warmup/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..66b6742956e --- /dev/null +++ b/base_field_trigger_warmup/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `KMEE `_: + + * Luis Felipe Mileo diff --git a/base_field_trigger_warmup/readme/DESCRIPTION.rst b/base_field_trigger_warmup/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..f848e492140 --- /dev/null +++ b/base_field_trigger_warmup/readme/DESCRIPTION.rst @@ -0,0 +1,15 @@ +The ORM resolves the transitive closure of compute triggers lazily: the first +time a field is written, Odoo walks its ``@api.depends`` graph and caches the +resulting trigger tree in the registry. On models with many interdependent +stored computed fields, that first write pays for the whole closure, so the +first request served by each worker is noticeably slower than the ones that +follow, and every restart brings the penalty back. + +This module builds those trees while the registry loads, where no user is +waiting. It has no user interface and no effect on behaviour: it only decides +*when* work the ORM would do anyway is done. + +It was extracted from a production deployment whose invoice line model carries +around 180 interdependent stored fields: the first write on a fresh worker cost +about five times what every following write cost. Warming up the trees at boot +removed the difference. diff --git a/base_field_trigger_warmup/readme/ROADMAP.rst b/base_field_trigger_warmup/readme/ROADMAP.rst new file mode 100644 index 00000000000..d181f386d51 --- /dev/null +++ b/base_field_trigger_warmup/readme/ROADMAP.rst @@ -0,0 +1,8 @@ +The module relies on ``Registry.get_field_trigger_tree``, which is private ORM +API. It is called defensively: if a future Odoo version renames or removes it, +the module logs the fact and does nothing instead of breaking the registry +load. Porting to a new version should start by checking that method. + +Warming up the trees hides the symptom of a wide compute graph. When the first +request of a worker is slow enough to need this module, it is also worth +looking at whether the graph itself can be reduced. diff --git a/base_field_trigger_warmup/readme/USAGE.rst b/base_field_trigger_warmup/readme/USAGE.rst new file mode 100644 index 00000000000..8504d60f6e9 --- /dev/null +++ b/base_field_trigger_warmup/readme/USAGE.rst @@ -0,0 +1,11 @@ +There is nothing to do: installing the module is enough. + +At the end of each registry load the module logs, at INFO level, how many +trigger trees it built and how long it took:: + + INFO odoo.addons.base_field_trigger_warmup: Warmed up 24868 field + trigger trees in 6.52s + +Use that line to decide whether the default scope is worth its boot cost, and +narrow it down with the system parameter described in the configuration section +if it is not. diff --git a/base_field_trigger_warmup/static/description/index.html b/base_field_trigger_warmup/static/description/index.html new file mode 100644 index 00000000000..b0771cdf052 --- /dev/null +++ b/base_field_trigger_warmup/static/description/index.html @@ -0,0 +1,491 @@ + + + + + +Field Trigger Tree Warmup + + + +
+ + + +Odoo Community Association + +
+

Field Trigger Tree Warmup

+ +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

The ORM resolves the transitive closure of compute triggers lazily: the first +time a field is written, Odoo walks its @api.depends graph and caches the +resulting trigger tree in the registry. On models with many interdependent +stored computed fields, that first write pays for the whole closure, so the +first request served by each worker is noticeably slower than the ones that +follow, and every restart brings the penalty back.

+

This module builds those trees while the registry loads, where no user is +waiting. It has no user interface and no effect on behaviour: it only decides +when work the ORM would do anyway is done.

+

It was extracted from a production deployment whose invoice line model carries +around 180 interdependent stored fields: the first write on a fresh worker cost +about five times what every following write cost. Warming up the trees at boot +removed the difference.

+

Table of contents

+ +
+

Configuration

+

By default every model in the registry is warmed up. On a large database that +costs a few seconds of boot time per worker, which is usually a good trade, +but it can be narrowed down.

+

To warm up only the models that matter, set the system parameter +base_field_trigger_warmup.models to a comma separated list of model names:

+
+base_field_trigger_warmup.models = account.move,account.move.line
+
+

Model names that do not exist in the registry are ignored with a warning. +Setting the parameter to * or leaving it empty restores the default of +warming up everything.

+

To disable the warmup entirely without uninstalling the module, for instance on +a development machine or in a CI pipeline where boot time matters more than the +first request, export:

+
+ODOO_FIELD_TRIGGER_WARMUP=0
+
+

The warmup is always skipped while tests are running.

+
+
+

Usage

+

There is nothing to do: installing the module is enough.

+

At the end of each registry load the module logs, at INFO level, how many +trigger trees it built and how long it took:

+
+INFO odoo.addons.base_field_trigger_warmup: Warmed up 24868 field
+trigger trees in 6.52s
+
+

Use that line to decide whether the default scope is worth its boot cost, and +narrow it down with the system parameter described in the configuration section +if it is not.

+
+
+

Known issues / Roadmap

+

The module relies on Registry.get_field_trigger_tree, which is private ORM +API. It is called defensively: if a future Odoo version renames or removes it, +the module logs the fact and does nothing instead of breaking the registry +load. Porting to a new version should start by checking that method.

+

Warming up the trees hides the symptom of a wide compute graph. When the first +request of a worker is slow enough to need this module, it is also worth +looking at whether the graph itself can be reduced.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/base_field_trigger_warmup/tests/__init__.py b/base_field_trigger_warmup/tests/__init__.py new file mode 100644 index 00000000000..c04bbb0788f --- /dev/null +++ b/base_field_trigger_warmup/tests/__init__.py @@ -0,0 +1 @@ +from . import test_field_trigger_warmup diff --git a/base_field_trigger_warmup/tests/test_field_trigger_warmup.py b/base_field_trigger_warmup/tests/test_field_trigger_warmup.py new file mode 100644 index 00000000000..07f73e2dff6 --- /dev/null +++ b/base_field_trigger_warmup/tests/test_field_trigger_warmup.py @@ -0,0 +1,90 @@ +# Copyright 2026 KMEE INFORMATICA LTDA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import os +from unittest.mock import patch + +from odoo.tests.common import TransactionCase + +from odoo.addons.base_field_trigger_warmup.models.base_field_trigger_warmup import ( + ENV_DISABLE, +) + + +class TestFieldTriggerWarmup(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.warmup = cls.env["base.field.trigger.warmup"] + cls.param = cls.env["ir.config_parameter"].sudo() + + def test_warms_up_the_requested_models(self): + """Every field of the given models has its trigger tree built.""" + built = [] + with patch.object( + type(self.env.registry), + "get_field_trigger_tree", + side_effect=lambda field: built.append(field), + ): + count = self.warmup._warmup_field_trigger_trees(["res.partner"]) + self.assertEqual(count, len(self.env["res.partner"]._fields)) + self.assertEqual(count, len(built)) + + def test_a_failing_field_does_not_break_the_warmup(self): + """A field whose tree cannot be built is skipped, not raised.""" + partner_fields = list(self.env["res.partner"]._fields.values()) + broken = partner_fields[0] + + def build(field): + if field is broken: + raise ValueError("cannot build this one") + + with patch.object( + type(self.env.registry), "get_field_trigger_tree", side_effect=build + ): + count = self.warmup._warmup_field_trigger_trees(["res.partner"]) + self.assertEqual(count, len(partner_fields) - 1) + + def test_missing_orm_api_is_a_no_op(self): + """An Odoo build without the private method must not break.""" + registry_type = type(self.env.registry) + with patch.object(registry_type, "get_field_trigger_tree", None, create=True): + self.assertEqual( + self.warmup._warmup_field_trigger_trees(["res.partner"]), 0 + ) + + def test_scope_defaults_to_every_model(self): + self.param.set_param("base_field_trigger_warmup.models", "*") + self.assertEqual( + sorted(self.warmup._warmup_model_names()), + sorted(self.env.registry), + ) + + def test_scope_can_be_narrowed(self): + self.param.set_param( + "base_field_trigger_warmup.models", "res.partner, res.users" + ) + self.assertEqual( + sorted(self.warmup._warmup_model_names()), + ["res.partner", "res.users"], + ) + + def test_unknown_models_are_ignored(self): + self.param.set_param( + "base_field_trigger_warmup.models", "res.partner,no.such.model" + ) + self.assertEqual(self.warmup._warmup_model_names(), ["res.partner"]) + + def test_enabled_by_default(self): + with patch.object(type(self.env.registry), "in_test_mode", return_value=False): + self.assertTrue(self.warmup._warmup_is_enabled()) + + def test_disabled_in_test_mode(self): + with patch.object(type(self.env.registry), "in_test_mode", return_value=True): + self.assertFalse(self.warmup._warmup_is_enabled()) + + def test_disabled_by_environment_variable(self): + with patch.dict(os.environ, {ENV_DISABLE: "0"}), patch.object( + type(self.env.registry), "in_test_mode", return_value=False + ): + self.assertFalse(self.warmup._warmup_is_enabled()) diff --git a/setup/base_field_trigger_warmup/odoo/addons/base_field_trigger_warmup b/setup/base_field_trigger_warmup/odoo/addons/base_field_trigger_warmup new file mode 120000 index 00000000000..64ef49fda41 --- /dev/null +++ b/setup/base_field_trigger_warmup/odoo/addons/base_field_trigger_warmup @@ -0,0 +1 @@ +../../../../base_field_trigger_warmup \ No newline at end of file diff --git a/setup/base_field_trigger_warmup/setup.py b/setup/base_field_trigger_warmup/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/base_field_trigger_warmup/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)