From 527883529aa3a9c11cfb1b293575add1e672fa5e Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Thu, 4 Jun 2026 22:34:54 +0200 Subject: [PATCH] [IMP] endpoint_route_handler: memoize route version per request _endpoint_route_last_version() is part of the routing_map ormcache key, so it runs on every routing map access -- e.g. once per url_for while rendering a website page, which adds up to dozens of identical 'SELECT last_value FROM endpoint_route_version' round-trips per page on content-heavy sites. The version cannot change within a request: memoize it on the request object. --- .../models/endpoint_route_sync_mixin.py | 2 + endpoint_route_handler/models/ir_http.py | 38 ++++++++++++++++++- endpoint_route_handler/tests/test_endpoint.py | 22 +++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/endpoint_route_handler/models/endpoint_route_sync_mixin.py b/endpoint_route_handler/models/endpoint_route_sync_mixin.py index f9d861c1..5966d3a2 100644 --- a/endpoint_route_handler/models/endpoint_route_sync_mixin.py +++ b/endpoint_route_handler/models/endpoint_route_sync_mixin.py @@ -95,6 +95,7 @@ def _register_controllers(self, init=False, options=None, clear_cache=True): self._endpoint_registry.update_rules(rules, init=init) if clear_cache: self.env.registry.clear_cache("routing") + self.env["ir.http"]._endpoint_route_reset_last_version() _logger.debug( "%s registered controllers: %s", self._name, @@ -107,6 +108,7 @@ def _unregister_controllers(self, clear_cache=True): self._endpoint_registry.drop_rules(self._registered_endpoint_rule_keys()) if clear_cache: self.env.registry.clear_cache("routing") + self.env["ir.http"]._endpoint_route_reset_last_version() _logger.debug( "%s unregistered controllers: %s", self._name, diff --git a/endpoint_route_handler/models/ir_http.py b/endpoint_route_handler/models/ir_http.py index e7b83c72..6d40c415 100644 --- a/endpoint_route_handler/models/ir_http.py +++ b/endpoint_route_handler/models/ir_http.py @@ -43,10 +43,44 @@ def routing_map(self, key=None): res = super().routing_map(key=key) return res + # Sentinel stored in the request `__dict__` to memoize the version. + _endpoint_route_version_attr = "_endpoint_route_last_version" + @classmethod def _endpoint_route_last_version(cls): - res = cls._get_routing_map_last_version(http.request.env) - return res + # This is part of the `routing_map` ormcache key, so it runs on + # every routing map access (e.g. once per `url_for` while rendering + # a website page). The version only changes when routes are + # (un)registered, which resets the memo via + # `_endpoint_route_reset_last_version`. Memoize it on the request + # object to avoid one SQL round-trip per call. + # NB: use `__dict__` rather than `getattr`: on a mocked request + # `getattr` would auto-create a child mock instead of falling back + # to the default, defeating the memoization. + # NB: `http.request` is a werkzeug `LocalProxy`; when no request is + # bound it is falsy (but not `None`), hence the truthiness check. + request = http.request + if not request: + return cls._get_routing_map_last_version(http.request.env) + version = request.__dict__.get(cls._endpoint_route_version_attr) + if version is None: + version = cls._get_routing_map_last_version(request.env) + request.__dict__[cls._endpoint_route_version_attr] = version + return version + + @classmethod + def _endpoint_route_reset_last_version(cls): + """Drop the memoized version so the next access reads it afresh. + + Must be called whenever routes are (un)registered within a request, + as the version changes and any value memoized earlier is now stale. + """ + # `http.request` is a werkzeug `LocalProxy`: falsy (but not `None`) + # when no request is bound, e.g. when (un)registering routes outside + # of an HTTP request (tests, crons, module install). + request = http.request + if request: + request.__dict__.pop(cls._endpoint_route_version_attr, None) @classmethod def _get_routing_map_last_version(cls, env): diff --git a/endpoint_route_handler/tests/test_endpoint.py b/endpoint_route_handler/tests/test_endpoint.py index 14901918..3a328e1e 100644 --- a/endpoint_route_handler/tests/test_endpoint.py +++ b/endpoint_route_handler/tests/test_endpoint.py @@ -115,6 +115,28 @@ def test_as_tool_register_controllers_dynamic_route(self): rmap = self.env["ir.http"].routing_map() self.assertIn(route, [x.rule for x in rmap._rules]) + def test_route_version_memoized_per_request(self): + """The route version is memoized on the request and reset on demand.""" + ir_http = self.env["ir.http"] + attr = ir_http._endpoint_route_version_attr + with self._get_mocked_request() as req: + # Nothing memoized yet. + self.assertNotIn(attr, req.__dict__) + + # First access reads the version and memoizes it on the request. + version = ir_http._endpoint_route_last_version() + self.assertEqual(req.__dict__[attr], version) + + # A memoized value is returned as-is: the source is not read + # again within the same request. Seed a sentinel to prove it. + req.__dict__[attr] = version + 999 + self.assertEqual(ir_http._endpoint_route_last_version(), version + 999) + + # Resetting drops the memo so the next access recomputes. + ir_http._endpoint_route_reset_last_version() + self.assertNotIn(attr, req.__dict__) + self.assertEqual(ir_http._endpoint_route_last_version(), version) + class TestEndpointCrossEnv(CommonEndpoint): def setUp(self):