Coverage for adhoc-cicd-odoo-odoo / odoo / service / model.py: 12%
160 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
1# Part of Odoo. See LICENSE file for full copyright and licensing details.
2import logging
3import random
4import threading
5import time
6from collections.abc import Mapping, Sequence
7from functools import partial
9from psycopg2 import IntegrityError, OperationalError, errorcodes, errors
11from odoo import api, http
12from odoo.exceptions import (
13 AccessDenied,
14 AccessError,
15 ConcurrencyError,
16 UserError,
17 ValidationError,
18)
19from odoo.models import BaseModel
20from odoo.modules.registry import Registry
21from odoo.tools import lazy
22from odoo.tools.safe_eval import _UNSAFE_ATTRIBUTES
24from .server import thread_local
26_logger = logging.getLogger(__name__)
28PG_CONCURRENCY_ERRORS_TO_RETRY = (errorcodes.LOCK_NOT_AVAILABLE, errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED)
29PG_CONCURRENCY_EXCEPTIONS_TO_RETRY = (errors.LockNotAvailable, errors.SerializationFailure, errors.DeadlockDetected)
30MAX_TRIES_ON_CONCURRENCY_FAILURE = 5
33class Params:
34 """Representation of parameters to a function call that can be stringified for display/logging"""
35 def __init__(self, args, kwargs):
36 self.args = args
37 self.kwargs = kwargs
39 def __str__(self):
40 params = [repr(arg) for arg in self.args]
41 params.extend(f"{key}={value!r}" for key, value in sorted(self.kwargs.items()))
42 return ', '.join(params)
45def get_public_method(model: BaseModel, name: str):
46 """ Get the public unbound method from a model.
48 When the method does not exist or is inaccessible, raise appropriate errors.
49 Accessible methods are public (in sense that python defined it:
50 not prefixed with "_") and are not decorated with `@api.private`.
51 """
52 assert isinstance(model, BaseModel)
53 e = f"Private methods (such as '{model._name}.{name}') cannot be called remotely."
54 if name.startswith('_') or name in _UNSAFE_ATTRIBUTES:
55 raise AccessError(e)
57 cls = type(model)
58 method = getattr(cls, name, None)
59 if not callable(method):
60 raise AttributeError(f"The method '{model._name}.{name}' does not exist") # noqa: TRY004
62 for mro_cls in cls.mro():
63 if not (cla_method := getattr(mro_cls, name, None)):
64 continue
65 if getattr(cla_method, '_api_private', False):
66 raise AccessError(e)
68 return method
71def call_kw(model: BaseModel, name: str, args: list, kwargs: Mapping):
72 """ Invoke the given method ``name`` on the recordset ``model``.
74 Private methods cannot be called, only ones returned by `get_public_method`.
75 """
76 method = get_public_method(model, name)
78 # get the records and context
79 if getattr(method, '_api_model', False):
80 # @api.model -> no ids
81 recs = model
82 else:
83 ids, args = args[0], args[1:]
84 recs = model.browse(ids)
86 # altering kwargs is a cause of errors, for instance when retrying a request
87 # after a serialization error: the retry is done without context!
88 kwargs = dict(kwargs)
89 context = kwargs.pop('context', None) or {}
90 recs = recs.with_context(context)
92 # call
93 _logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs))
94 result = method(recs, *args, **kwargs)
96 # adapt the result
97 if name == "create":
98 # special case for method 'create'
99 result = result.id if isinstance(args[0], Mapping) else result.ids
100 elif isinstance(result, BaseModel):
101 result = result.ids
103 return result
106def dispatch(method, params):
107 db, uid, passwd, model, method_, *args = params
108 uid = int(uid)
109 if not passwd:
110 raise AccessDenied
111 # access checked once we open a cursor
113 threading.current_thread().dbname = db
114 threading.current_thread().uid = uid
115 registry = Registry(db).check_signaling()
116 try:
117 if method == 'execute':
118 kw = {}
119 elif method == 'execute_kw':
120 # accept: (args, kw=None)
121 if len(args) == 1:
122 args += ({},)
123 args, kw = args
124 if kw is None:
125 kw = {}
126 else:
127 raise NameError(f"Method not available {method}") # noqa: TRY301
128 with registry.cursor() as cr:
129 api.Environment(cr, api.SUPERUSER_ID, {})['res.users']._check_uid_passwd(uid, passwd)
130 res = execute_cr(cr, uid, model, method_, args, kw)
131 registry.signal_changes()
132 except Exception:
133 registry.reset_changes()
134 raise
135 return res
138def execute_cr(cr, uid, obj, method, args, kw):
139 # clean cache etc if we retry the same transaction
140 cr.reset()
141 env = api.Environment(cr, uid, {})
142 env.transaction.default_env = env # ensure this is the default env for the call
143 recs = env.get(obj)
144 if recs is None:
145 raise UserError(f"Object {obj} doesn't exist") # pylint: disable=missing-gettext
146 thread_local.rpc_model_method = f'{obj}.{method}'
147 result = retrying(partial(call_kw, recs, method, args, kw), env)
148 # force evaluation of lazy values before the cursor is closed, as it would
149 # error afterwards if the lazy isn't already evaluated (and cached)
150 for l in _traverse_containers(result, lazy):
151 _0 = l._value
152 if result is None:
153 _logger.info('The method %s of the object %s cannot return `None`!', method, obj)
154 return result
157def retrying(func, env):
158 """
159 Call ``func``in a loop until the SQL transaction commits with no
160 serialisation error. It rollbacks the transaction in between calls.
162 A serialisation error occurs when two independent transactions
163 attempt to commit incompatible changes such as writing different
164 values on a same record. The first transaction to commit works, the
165 second is canceled with a :class:`psycopg2.errors.SerializationFailure`.
167 This function intercepts those serialization errors, rollbacks the
168 transaction, reset things that might have been modified, waits a
169 random bit, and then calls the function again.
171 It calls the function up to ``MAX_TRIES_ON_CONCURRENCY_FAILURE`` (5)
172 times. The time it waits between calls is random with an exponential
173 backoff: ``random.uniform(0.0, 2 ** i)`` where ``i`` is the n° of
174 the current attempt and starts at 1.
176 :param callable func: The function to call, you can pass arguments
177 using :func:`functools.partial`.
178 :param odoo.api.Environment env: The environment where the registry
179 and the cursor are taken.
180 """
181 try:
182 for tryno in range(1, MAX_TRIES_ON_CONCURRENCY_FAILURE + 1):
183 tryleft = MAX_TRIES_ON_CONCURRENCY_FAILURE - tryno
184 try:
185 result = func()
186 if not env.cr._closed:
187 env.cr.flush() # submit the changes to the database
188 break
189 except (IntegrityError, OperationalError, ConcurrencyError) as exc:
190 if env.cr._closed:
191 raise
192 env.cr.rollback()
193 env.transaction.reset()
194 env.registry.reset_changes()
195 request = http.request
196 if request:
197 request.session = request._get_session_and_dbname()[0]
198 # Rewind files in case of failure
199 for filename, file in request.httprequest.files.items():
200 if hasattr(file, "seekable") and file.seekable():
201 file.seek(0)
202 else:
203 raise RuntimeError(f"Cannot retry request on input file {filename!r} after serialization failure") from exc
204 if isinstance(exc, IntegrityError):
205 model = env['base']
206 for rclass in env.registry.values():
207 if exc.diag.table_name == rclass._table:
208 model = env[rclass._name]
209 break
210 message = env._("The operation cannot be completed: %s", model._sql_error_to_message(exc))
211 raise ValidationError(message) from exc
213 if isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY):
214 error = errorcodes.lookup(exc.pgcode)
215 elif isinstance(exc, ConcurrencyError):
216 error = repr(exc)
217 else:
218 raise
219 if not tryleft:
220 _logger.info("%s, maximum number of tries reached!", error)
221 raise
223 wait_time = random.uniform(0.0, 2 ** tryno)
224 _logger.info("%s, %s tries left, try again in %.04f sec...", error, tryleft, wait_time)
225 time.sleep(wait_time)
226 else:
227 # handled in the "if not tryleft" case
228 raise RuntimeError("unreachable")
230 except Exception:
231 env.transaction.reset()
232 env.registry.reset_changes()
233 raise
235 if not env.cr.closed:
236 env.cr.commit() # effectively commits and execute post-commits
237 env.registry.signal_changes()
238 return result
241def _traverse_containers(val, type_):
242 """ Yields atoms filtered by specified ``type_`` (or type tuple), traverses
243 through standard containers (non-string mappings or sequences) *unless*
244 they're selected by the type filter
245 """
246 from odoo.models import BaseModel
247 if isinstance(val, type_):
248 yield val
249 elif isinstance(val, (str, bytes, BaseModel)):
250 return
251 elif isinstance(val, Mapping):
252 for k, v in val.items():
253 yield from _traverse_containers(k, type_)
254 yield from _traverse_containers(v, type_)
255 elif isinstance(val, Sequence):
256 for v in val:
257 yield from _traverse_containers(v, type_)