oca-ocb-core/odoo-bringout-oca-ocb-base/odoo/service/model.py
Ernad Husremovic 2d3ee4855a 19.0 vanilla
2026-03-09 09:30:27 +01:00

257 lines
9.5 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import random
import threading
import time
from collections.abc import Mapping, Sequence
from functools import partial
from psycopg2 import IntegrityError, OperationalError, errorcodes, errors
from odoo import api, http
from odoo.exceptions import (
AccessDenied,
AccessError,
ConcurrencyError,
UserError,
ValidationError,
)
from odoo.models import BaseModel
from odoo.modules.registry import Registry
from odoo.tools import lazy
from odoo.tools.safe_eval import _UNSAFE_ATTRIBUTES
from .server import thread_local
_logger = logging.getLogger(__name__)
PG_CONCURRENCY_ERRORS_TO_RETRY = (errorcodes.LOCK_NOT_AVAILABLE, errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED)
PG_CONCURRENCY_EXCEPTIONS_TO_RETRY = (errors.LockNotAvailable, errors.SerializationFailure, errors.DeadlockDetected)
MAX_TRIES_ON_CONCURRENCY_FAILURE = 5
class Params:
"""Representation of parameters to a function call that can be stringified for display/logging"""
def __init__(self, args, kwargs):
self.args = args
self.kwargs = kwargs
def __str__(self):
params = [repr(arg) for arg in self.args]
params.extend(f"{key}={value!r}" for key, value in sorted(self.kwargs.items()))
return ', '.join(params)
def get_public_method(model: BaseModel, name: str):
""" Get the public unbound method from a model.
When the method does not exist or is inaccessible, raise appropriate errors.
Accessible methods are public (in sense that python defined it:
not prefixed with "_") and are not decorated with `@api.private`.
"""
assert isinstance(model, BaseModel)
e = f"Private methods (such as '{model._name}.{name}') cannot be called remotely."
if name.startswith('_') or name in _UNSAFE_ATTRIBUTES:
raise AccessError(e)
cls = type(model)
method = getattr(cls, name, None)
if not callable(method):
raise AttributeError(f"The method '{model._name}.{name}' does not exist") # noqa: TRY004
for mro_cls in cls.mro():
if not (cla_method := getattr(mro_cls, name, None)):
continue
if getattr(cla_method, '_api_private', False):
raise AccessError(e)
return method
def call_kw(model: BaseModel, name: str, args: list, kwargs: Mapping):
""" Invoke the given method ``name`` on the recordset ``model``.
Private methods cannot be called, only ones returned by `get_public_method`.
"""
method = get_public_method(model, name)
# get the records and context
if getattr(method, '_api_model', False):
# @api.model -> no ids
recs = model
else:
ids, args = args[0], args[1:]
recs = model.browse(ids)
# altering kwargs is a cause of errors, for instance when retrying a request
# after a serialization error: the retry is done without context!
kwargs = dict(kwargs)
context = kwargs.pop('context', None) or {}
recs = recs.with_context(context)
# call
_logger.debug("call %s.%s(%s)", recs, method.__name__, Params(args, kwargs))
result = method(recs, *args, **kwargs)
# adapt the result
if name == "create":
# special case for method 'create'
result = result.id if isinstance(args[0], Mapping) else result.ids
elif isinstance(result, BaseModel):
result = result.ids
return result
def dispatch(method, params):
db, uid, passwd, model, method_, *args = params
uid = int(uid)
if not passwd:
raise AccessDenied
# access checked once we open a cursor
threading.current_thread().dbname = db
threading.current_thread().uid = uid
registry = Registry(db).check_signaling()
try:
if method == 'execute':
kw = {}
elif method == 'execute_kw':
# accept: (args, kw=None)
if len(args) == 1:
args += ({},)
args, kw = args
if kw is None:
kw = {}
else:
raise NameError(f"Method not available {method}") # noqa: TRY301
with registry.cursor() as cr:
api.Environment(cr, api.SUPERUSER_ID, {})['res.users']._check_uid_passwd(uid, passwd)
res = execute_cr(cr, uid, model, method_, args, kw)
registry.signal_changes()
except Exception:
registry.reset_changes()
raise
return res
def execute_cr(cr, uid, obj, method, args, kw):
# clean cache etc if we retry the same transaction
cr.reset()
env = api.Environment(cr, uid, {})
env.transaction.default_env = env # ensure this is the default env for the call
recs = env.get(obj)
if recs is None:
raise UserError(f"Object {obj} doesn't exist") # pylint: disable=missing-gettext
thread_local.rpc_model_method = f'{obj}.{method}'
result = retrying(partial(call_kw, recs, method, args, kw), env)
# force evaluation of lazy values before the cursor is closed, as it would
# error afterwards if the lazy isn't already evaluated (and cached)
for l in _traverse_containers(result, lazy):
_0 = l._value
if result is None:
_logger.info('The method %s of the object %s cannot return `None`!', method, obj)
return result
def retrying(func, env):
"""
Call ``func``in a loop until the SQL transaction commits with no
serialisation error. It rollbacks the transaction in between calls.
A serialisation error occurs when two independent transactions
attempt to commit incompatible changes such as writing different
values on a same record. The first transaction to commit works, the
second is canceled with a :class:`psycopg2.errors.SerializationFailure`.
This function intercepts those serialization errors, rollbacks the
transaction, reset things that might have been modified, waits a
random bit, and then calls the function again.
It calls the function up to ``MAX_TRIES_ON_CONCURRENCY_FAILURE`` (5)
times. The time it waits between calls is random with an exponential
backoff: ``random.uniform(0.0, 2 ** i)`` where ``i`` is the n° of
the current attempt and starts at 1.
:param callable func: The function to call, you can pass arguments
using :func:`functools.partial`.
:param odoo.api.Environment env: The environment where the registry
and the cursor are taken.
"""
try:
for tryno in range(1, MAX_TRIES_ON_CONCURRENCY_FAILURE + 1):
tryleft = MAX_TRIES_ON_CONCURRENCY_FAILURE - tryno
try:
result = func()
if not env.cr._closed:
env.cr.flush() # submit the changes to the database
break
except (IntegrityError, OperationalError, ConcurrencyError) as exc:
if env.cr._closed:
raise
env.cr.rollback()
env.transaction.reset()
env.registry.reset_changes()
request = http.request
if request:
request.session = request._get_session_and_dbname()[0]
# Rewind files in case of failure
for filename, file in request.httprequest.files.items():
if hasattr(file, "seekable") and file.seekable():
file.seek(0)
else:
raise RuntimeError(f"Cannot retry request on input file {filename!r} after serialization failure") from exc
if isinstance(exc, IntegrityError):
model = env['base']
for rclass in env.registry.values():
if exc.diag.table_name == rclass._table:
model = env[rclass._name]
break
message = env._("The operation cannot be completed: %s", model._sql_error_to_message(exc))
raise ValidationError(message) from exc
if isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY):
error = errorcodes.lookup(exc.pgcode)
elif isinstance(exc, ConcurrencyError):
error = repr(exc)
else:
raise
if not tryleft:
_logger.info("%s, maximum number of tries reached!", error)
raise
wait_time = random.uniform(0.0, 2 ** tryno)
_logger.info("%s, %s tries left, try again in %.04f sec...", error, tryleft, wait_time)
time.sleep(wait_time)
else:
# handled in the "if not tryleft" case
raise RuntimeError("unreachable")
except Exception:
env.transaction.reset()
env.registry.reset_changes()
raise
if not env.cr.closed:
env.cr.commit() # effectively commits and execute post-commits
env.registry.signal_changes()
return result
def _traverse_containers(val, type_):
""" Yields atoms filtered by specified ``type_`` (or type tuple), traverses
through standard containers (non-string mappings or sequences) *unless*
they're selected by the type filter
"""
from odoo.models import BaseModel
if isinstance(val, type_):
yield val
elif isinstance(val, (str, bytes, BaseModel)):
return
elif isinstance(val, Mapping):
for k, v in val.items():
yield from _traverse_containers(k, type_)
yield from _traverse_containers(v, type_)
elif isinstance(val, Sequence):
for v in val:
yield from _traverse_containers(v, type_)