Skip to content

Commit 68f074c

Browse files
committed
[ADD] base_field_trigger_warmup: build compute dependency trees at boot
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 the first request served by each worker pays for the whole closure, and every restart brings the penalty back. This module builds those trees while the registry loads, where nobody is waiting. Behaviour is unchanged: it only decides when work the ORM would do anyway is done. The scope defaults to every model and can be narrowed with the base_field_trigger_warmup.models system parameter; ODOO_FIELD_TRIGGER_WARMUP=0 disables it without uninstalling. Registry.get_field_trigger_tree is private ORM API, so it is called defensively: a version that renames or drops it degrades to a no-op instead of breaking the registry load. Extracted from a production deployment whose invoice line model carries around 180 interdependent stored fields, where the first write on a fresh worker cost about five times what every following write cost.
1 parent aca22c1 commit 68f074c

15 files changed

Lines changed: 930 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
.. image:: https://odoo-community.org/readme-banner-image
2+
:target: https://odoo-community.org/get-involved?utm_source=readme
3+
:alt: Odoo Community Association
4+
5+
=========================
6+
Field Trigger Tree Warmup
7+
=========================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:c32021bf286316f53f09e2a7ef5dce116ebfce5f52d8584ad1d093a4ca6a9b76
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Beta
20+
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
22+
:alt: License: AGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github
24+
:target: https://github.com/OCA/server-tools/tree/16.0/base_field_trigger_warmup
25+
:alt: OCA/server-tools
26+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
27+
:target: https://translation.odoo-community.org/projects/server-tools-16-0/server-tools-16-0-base_field_trigger_warmup
28+
:alt: Translate me on Weblate
29+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
30+
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=16.0
31+
:alt: Try me on Runboat
32+
33+
|badge1| |badge2| |badge3| |badge4| |badge5|
34+
35+
The ORM resolves the transitive closure of compute triggers lazily: the first
36+
time a field is written, Odoo walks its ``@api.depends`` graph and caches the
37+
resulting trigger tree in the registry. On models with many interdependent
38+
stored computed fields, that first write pays for the whole closure, so the
39+
first request served by each worker is noticeably slower than the ones that
40+
follow, and every restart brings the penalty back.
41+
42+
This module builds those trees while the registry loads, where no user is
43+
waiting. It has no user interface and no effect on behaviour: it only decides
44+
*when* work the ORM would do anyway is done.
45+
46+
It was extracted from a production deployment whose invoice line model carries
47+
around 180 interdependent stored fields: the first write on a fresh worker cost
48+
about five times what every following write cost. Warming up the trees at boot
49+
removed the difference.
50+
51+
**Table of contents**
52+
53+
.. contents::
54+
:local:
55+
56+
Configuration
57+
=============
58+
59+
By default every model in the registry is warmed up. On a large database that
60+
costs a few seconds of boot time per worker, which is usually a good trade,
61+
but it can be narrowed down.
62+
63+
To warm up only the models that matter, set the system parameter
64+
``base_field_trigger_warmup.models`` to a comma separated list of model names::
65+
66+
base_field_trigger_warmup.models = account.move,account.move.line
67+
68+
Model names that do not exist in the registry are ignored with a warning.
69+
Setting the parameter to ``*`` or leaving it empty restores the default of
70+
warming up everything.
71+
72+
To disable the warmup entirely without uninstalling the module, for instance on
73+
a development machine or in a CI pipeline where boot time matters more than the
74+
first request, export::
75+
76+
ODOO_FIELD_TRIGGER_WARMUP=0
77+
78+
The warmup is always skipped while tests are running.
79+
80+
Usage
81+
=====
82+
83+
There is nothing to do: installing the module is enough.
84+
85+
At the end of each registry load the module logs, at INFO level, how many
86+
trigger trees it built and how long it took::
87+
88+
INFO odoo.addons.base_field_trigger_warmup: Warmed up 24868 field
89+
trigger trees in 6.52s
90+
91+
Use that line to decide whether the default scope is worth its boot cost, and
92+
narrow it down with the system parameter described in the configuration section
93+
if it is not.
94+
95+
Known issues / Roadmap
96+
======================
97+
98+
The module relies on ``Registry.get_field_trigger_tree``, which is private ORM
99+
API. It is called defensively: if a future Odoo version renames or removes it,
100+
the module logs the fact and does nothing instead of breaking the registry
101+
load. Porting to a new version should start by checking that method.
102+
103+
Warming up the trees hides the symptom of a wide compute graph. When the first
104+
request of a worker is slow enough to need this module, it is also worth
105+
looking at whether the graph itself can be reduced.
106+
107+
Bug Tracker
108+
===========
109+
110+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
111+
In case of trouble, please check there if your issue has already been reported.
112+
If you spotted it first, help us to smash it by providing a detailed and welcomed
113+
`feedback <https://github.com/OCA/server-tools/issues/new?body=module:%20base_field_trigger_warmup%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
114+
115+
Do not contact contributors directly about support or help with technical issues.
116+
117+
Credits
118+
=======
119+
120+
Authors
121+
~~~~~~~
122+
123+
* KMEE
124+
125+
Contributors
126+
~~~~~~~~~~~~
127+
128+
* `KMEE <https://www.kmee.com.br>`_:
129+
130+
* Luis Felipe Mileo <mileo@kmee.com.br>
131+
132+
Maintainers
133+
~~~~~~~~~~~
134+
135+
This module is maintained by the OCA.
136+
137+
.. image:: https://odoo-community.org/logo.png
138+
:alt: Odoo Community Association
139+
:target: https://odoo-community.org
140+
141+
OCA, or the Odoo Community Association, is a nonprofit organization whose
142+
mission is to support the collaborative development of Odoo features and
143+
promote its widespread use.
144+
145+
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/16.0/base_field_trigger_warmup>`_ project on GitHub.
146+
147+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 KMEE INFORMATICA LTDA
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
{
4+
"name": "Field Trigger Tree Warmup",
5+
"summary": "Build the compute dependency trees at boot instead of on the "
6+
"first request",
7+
"version": "16.0.1.0.0",
8+
"development_status": "Beta",
9+
"category": "Tools",
10+
"author": "KMEE, Odoo Community Association (OCA)",
11+
"website": "https://github.com/OCA/server-tools",
12+
"license": "AGPL-3",
13+
"depends": ["base"],
14+
"installable": True,
15+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import base_field_trigger_warmup
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright 2026 KMEE INFORMATICA LTDA
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
import logging
5+
import os
6+
import time
7+
8+
from odoo import api, models
9+
10+
_logger = logging.getLogger(__name__)
11+
12+
PARAM_MODELS = "base_field_trigger_warmup.models"
13+
ENV_DISABLE = "ODOO_FIELD_TRIGGER_WARMUP"
14+
15+
16+
class BaseFieldTriggerWarmup(models.AbstractModel):
17+
"""Build the ORM compute dependency trees while the registry loads.
18+
19+
The ORM resolves the transitive closure of compute triggers lazily, the
20+
first time a field is written. On models with many interdependent stored
21+
computed fields, that first write pays for the whole closure, so the first
22+
request served by each worker is much slower than the following ones. This
23+
model moves that cost to the registry load, where no user is waiting.
24+
"""
25+
26+
_name = "base.field.trigger.warmup"
27+
_description = "Field Trigger Tree Warmup"
28+
29+
@api.model
30+
def _warmup_is_enabled(self):
31+
"""Warmup is skipped in tests and when the env var is set to 0."""
32+
if os.environ.get(ENV_DISABLE, "1") == "0":
33+
return False
34+
return not self.env.registry.in_test_mode()
35+
36+
@api.model
37+
def _warmup_model_names(self):
38+
"""Model names to warm up.
39+
40+
Read from the ``base_field_trigger_warmup.models`` system parameter: a
41+
comma separated list of model names, or ``*`` (the default) for every
42+
model in the registry. Narrowing it down is useful when only a few
43+
models are expensive and boot time matters.
44+
"""
45+
param = (
46+
self.env["ir.config_parameter"].sudo().get_param(PARAM_MODELS, default="*")
47+
or ""
48+
).strip()
49+
if param in ("", "*"):
50+
return list(self.env.registry)
51+
wanted = [name.strip() for name in param.split(",") if name.strip()]
52+
known, unknown = [], []
53+
for name in wanted:
54+
(known if name in self.env.registry else unknown).append(name)
55+
if unknown:
56+
_logger.warning(
57+
"%s lists unknown models, ignored: %s",
58+
PARAM_MODELS,
59+
", ".join(unknown),
60+
)
61+
return known
62+
63+
@api.model
64+
def _warmup_field_trigger_trees(self, model_names=None):
65+
"""Build the trigger tree of every field of ``model_names``.
66+
67+
Returns the number of fields whose tree was built. Failures on a single
68+
field are logged at debug level and skipped: a warmup must never be able
69+
to break the boot.
70+
"""
71+
registry = self.env.registry
72+
# Private ORM API: guard it so an Odoo version that renames or drops it
73+
# degrades to a no-op instead of breaking the registry load.
74+
build_tree = getattr(registry, "get_field_trigger_tree", None)
75+
if build_tree is None:
76+
_logger.info(
77+
"This Odoo build has no Registry.get_field_trigger_tree, "
78+
"nothing to warm up"
79+
)
80+
return 0
81+
if model_names is None:
82+
model_names = self._warmup_model_names()
83+
count = 0
84+
for model_name in model_names:
85+
model = self.env.get(model_name)
86+
if model is None:
87+
continue
88+
for field in model._fields.values():
89+
try:
90+
build_tree(field)
91+
except Exception: # pylint: disable=except-pass
92+
_logger.debug(
93+
"Could not build the trigger tree of %s.%s",
94+
model_name,
95+
field.name,
96+
exc_info=True,
97+
)
98+
continue
99+
count += 1
100+
return count
101+
102+
def _register_hook(self):
103+
res = super()._register_hook()
104+
registry = self.env.registry
105+
# _register_hook runs once per model that defines it, and the registry
106+
# may be loaded more than once per process.
107+
if getattr(registry, "_field_trigger_warmup_done", False):
108+
return res
109+
registry._field_trigger_warmup_done = True
110+
if not self._warmup_is_enabled():
111+
return res
112+
start = time.time()
113+
count = self._warmup_field_trigger_trees()
114+
if count:
115+
_logger.info(
116+
"Warmed up %s field trigger trees in %.2fs",
117+
count,
118+
time.time() - start,
119+
)
120+
return res
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
By default every model in the registry is warmed up. On a large database that
2+
costs a few seconds of boot time per worker, which is usually a good trade,
3+
but it can be narrowed down.
4+
5+
To warm up only the models that matter, set the system parameter
6+
``base_field_trigger_warmup.models`` to a comma separated list of model names::
7+
8+
base_field_trigger_warmup.models = account.move,account.move.line
9+
10+
Model names that do not exist in the registry are ignored with a warning.
11+
Setting the parameter to ``*`` or leaving it empty restores the default of
12+
warming up everything.
13+
14+
To disable the warmup entirely without uninstalling the module, for instance on
15+
a development machine or in a CI pipeline where boot time matters more than the
16+
first request, export::
17+
18+
ODOO_FIELD_TRIGGER_WARMUP=0
19+
20+
The warmup is always skipped while tests are running.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
* `KMEE <https://www.kmee.com.br>`_:
2+
3+
* Luis Felipe Mileo <mileo@kmee.com.br>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
The ORM resolves the transitive closure of compute triggers lazily: the first
2+
time a field is written, Odoo walks its ``@api.depends`` graph and caches the
3+
resulting trigger tree in the registry. On models with many interdependent
4+
stored computed fields, that first write pays for the whole closure, so the
5+
first request served by each worker is noticeably slower than the ones that
6+
follow, and every restart brings the penalty back.
7+
8+
This module builds those trees while the registry loads, where no user is
9+
waiting. It has no user interface and no effect on behaviour: it only decides
10+
*when* work the ORM would do anyway is done.
11+
12+
It was extracted from a production deployment whose invoice line model carries
13+
around 180 interdependent stored fields: the first write on a fresh worker cost
14+
about five times what every following write cost. Warming up the trees at boot
15+
removed the difference.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
The module relies on ``Registry.get_field_trigger_tree``, which is private ORM
2+
API. It is called defensively: if a future Odoo version renames or removes it,
3+
the module logs the fact and does nothing instead of breaking the registry
4+
load. Porting to a new version should start by checking that method.
5+
6+
Warming up the trees hides the symptom of a wide compute graph. When the first
7+
request of a worker is slow enough to need this module, it is also worth
8+
looking at whether the graph itself can be reduced.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
There is nothing to do: installing the module is enough.
2+
3+
At the end of each registry load the module logs, at INFO level, how many
4+
trigger trees it built and how long it took::
5+
6+
INFO odoo.addons.base_field_trigger_warmup: Warmed up 24868 field
7+
trigger trees in 6.52s
8+
9+
Use that line to decide whether the default scope is worth its boot cost, and
10+
narrow it down with the system parameter described in the configuration section
11+
if it is not.

0 commit comments

Comments
 (0)