mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 04:52:02 +02:00
257 lines
9.5 KiB
Python
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_)
|