Add timeout parameter on RPC calls

Closes #11293
This commit is contained in:
Nicolas Évrard
2023-10-08 00:08:52 +02:00
parent 6ea6082b78
commit a42cb450f7
28 changed files with 154 additions and 50 deletions
+4 -2
View File
@@ -721,9 +721,11 @@
if (!(selection instanceof Array) &&
!(key in this._values2selection)) {
if (!jQuery.isEmptyObject(this.attributes.selection_change_with)) {
prm = this.model.execute(selection, [value]);
prm = this.model.execute(
selection, [value], {}, true, false);
} else {
prm = this.model.execute(selection, []);
prm = this.model.execute(
selection, [], {}, true, false);
}
prm = prm.then(selection => {
this._values2selection[key] = selection;
+1 -1
View File
@@ -1226,7 +1226,7 @@
try {
result = this.model.execute(
'autocomplete_' + fieldname, [values], this.get_context(),
false);
false, false);
} catch (e) {
result = [];
}
+10
View File
@@ -113,6 +113,11 @@
};
var ajax_error = function(query, status_, error) {
if (!process_exception) {
console.debug(`RPC error calling ${args}: ${status_}: ${error}.`);
dfd.reject();
return;
}
if (query.status == 401) {
//Try to relog
Sao.Session.renew(session).then(function() {
@@ -123,6 +128,11 @@
dfd.resolve();
}
}, dfd.reject);
} else if (query.status == 408) {
Sao.common.message.run(
Sao.i18n.gettext('Request times out. Try again later.'),
'tryton-error')
.always(dfd.reject);
} else if (query.status == 429) {
Sao.common.message.run(
Sao.i18n.gettext('Too many requests. Try again later.'),
+1 -1
View File
@@ -1021,7 +1021,7 @@ var Sao = {
}
return ir_model.execute('global_search',
[text, Sao.config.limit, Sao.main_menu_screen.model_name],
Sao.main_menu_screen.context)
Sao.main_menu_screen.context, true, false)
.then(s_results => {
var results = [];
for (const result of s_results) {
+4 -3
View File
@@ -1068,7 +1068,8 @@
(ids.length == this.limit)) {
count_prm = this.model.execute(
'search_count',
[domain, 0, this.count_limit], context)
[domain, 0, this.count_limit], context,
true, false)
.then(count => {
this.search_count = count;
return this.search_count;
@@ -1807,10 +1808,10 @@
values[p] = null;
}
selection = this.model.execute(props.selection,
[values], undefined, false);
[values], undefined, false, true);
} else {
selection = this.model.execute(props.selection,
[], undefined, false);
[], undefined, false, true);
}
return selection.sort(function(a, b) {
return a[1].localeCompare(b[1]);
+8 -6
View File
@@ -1076,11 +1076,12 @@ function eval_pyson(value){
'method': (
'model.' + action.res_model + '.search_count'),
'params': [
['AND', domain, tab_domain], 0, 100, context],
}, Sao.Session.current_session).then(value => {
this._set_count(
value, i, current, counter,
action.name, tab_domains);
['AND', domain, 0, tab_domain], 100, context],
}, Sao.Session.current_session, true, false).then(
value => {
this._set_count(
value, i, current, counter,
action.name, tab_domains);
});
promesses.push(prm);
}, this);
@@ -1089,7 +1090,8 @@ function eval_pyson(value){
'method': (
'model.' + action.res_model + '.search_count'),
'params': [domain, 0, 100, context],
}, Sao.Session.current_session).then(value => {
}, Sao.Session.current_session, true, false
).then(value => {
this._set_count(
value, 0, current, counter,
action.name, tab_domains);
+4
View File
@@ -1014,6 +1014,10 @@ def process_exception(exception, *args, **kwargs):
PLOCK.release()
if args:
return rpc_execute(*args)
elif exception.faultCode == str(int(HTTPStatus.REQUEST_TIMEOUT)):
message(
_("Request times out. Try again later."),
msg_type=Gtk.MessageType.ERROR)
elif exception.faultCode == str(int(HTTPStatus.TOO_MANY_REQUESTS)):
message(
_('Too many requests. Try again later.'),
+6 -3
View File
@@ -30,10 +30,13 @@ class SelectionMixin(object):
and key not in self._values2selection):
try:
if self.attrs.get('selection_change_with'):
selection = RPCExecute('model', self.model_name, selection,
value)
selection = RPCExecute(
'model', self.model_name, selection, value,
process_exception=False)
else:
selection = RPCExecute('model', self.model_name, selection)
selection = RPCExecute(
'model', self.model_name, selection,
process_exception=False)
except RPCException:
selection = []
self._values2selection[key] = selection
+5 -3
View File
@@ -23,7 +23,8 @@ from tryton.common import RPCContextReload, RPCException, RPCExecute
from tryton.common.cellrendererclickablepixbuf import (
CellRendererClickablePixbuf)
from tryton.config import CONFIG, TRYTON_ICON, get_config_dir
from tryton.exceptions import TrytonError, TrytonServerUnavailable
from tryton.exceptions import (
TrytonError, TrytonServerError, TrytonServerUnavailable)
from tryton.gui.window import Window
from tryton.jsonrpc import object_hook
from tryton.pyson import PYSONDecoder
@@ -375,7 +376,7 @@ class Main(Gtk.Application):
def set_result(result):
try:
result = result()
except RPCException:
except (RPCException, TrytonServerError):
result = []
if search_text != widget.get_text():
if callback:
@@ -401,7 +402,8 @@ class Main(Gtk.Application):
RPCExecute('model', 'ir.model', 'global_search', search_text,
CONFIG['client.limit'], self.menu_screen.model_name,
context=self.menu_screen.context, callback=set_result)
context=self.menu_screen.context, callback=set_result,
process_exception=False)
return False
def changed(widget):
@@ -5,6 +5,7 @@ import logging
import tryton.common as common
from tryton.common import RPCException, RPCExecute
from tryton.config import CONFIG
from tryton.exceptions import TrytonServerError
from tryton.pyson import PYSONDecoder
from . import field as fields
@@ -677,8 +678,9 @@ class Record:
try:
res = RPCExecute(
'model', self.model_name,
'autocomplete_' + fieldname, args, context=self.get_context())
except RPCException:
'autocomplete_' + fieldname, args, context=self.get_context(),
process_exception=False)
except (RPCException, TrytonServerError):
# ensure res is a list
res = []
self.autocompletion[fieldname] = res
@@ -21,6 +21,7 @@ from tryton.common import (
from tryton.common.domain_inversion import canonicalize
from tryton.common.domain_parser import DomainParser
from tryton.config import CONFIG
from tryton.exceptions import TrytonServerError
from tryton.gui.window.infobar import InfoBar
from tryton.gui.window.view_form.model.group import Group
from tryton.gui.window.view_form.view import View
@@ -308,8 +309,9 @@ class Screen:
try:
self.search_count = RPCExecute(
'model', self.model_name, 'search_count',
domain, 0, self.count_limit, context=context)
except RPCException:
domain, 0, self.count_limit, context=context,
process_exception=False)
except (RPCException, TrytonServerError):
self.search_count = 0
else:
self.search_count = len(ids)
@@ -7,6 +7,7 @@ from gi.repository import Gtk
import tryton.common as common
from tryton.action import Action
from tryton.config import CONFIG
from tryton.exceptions import TrytonServerError
from tryton.pyson import PYSONDecoder
@@ -189,14 +190,16 @@ class Link(StateMixin, Gtk.Button):
['AND', domain, tab_domain], 0, 100, context=context,
callback=functools.partial(
self._set_count, idx=i, current=self._current,
counter=counter, label=label))
counter=counter, label=label),
process_exception=False)
else:
common.RPCExecute(
'model', action['res_model'], 'search_count',
domain, 0, 100, context=context,
callback=functools.partial(
self._set_count, current=self._current,
counter=counter, label=label))
counter=counter, label=label),
process_exception=False)
def _set_count(self, value, idx=0, current=None, counter=None, label=''):
if current != self._current or not self.get_parent():
@@ -206,7 +209,7 @@ class Link(StateMixin, Gtk.Button):
if count > 99:
count = '99+'
counter[idx] = count
except common.RPCException:
except (common.RPCException, TrytonServerError):
pass
self._set_label_counter(label, counter)
+1
View File
@@ -1,3 +1,4 @@
* Add timeout parameter on RPC definitions
Version 7.0.0 - 2023-10-30
--------------------------
+7 -1
View File
@@ -4,7 +4,7 @@
RPC
===
.. class:: RPC([readonly[, instantiate[, [decorator, result[, check_access[, unique[, fresh_session[, cache]]]]]]]])
.. class:: RPC([readonly[, instantiate[, [decorator, result[, check_access[, unique[, fresh_session[, cache[, timeout]]]]]]]]])
Define the behavior of Remote Procedure Call.
@@ -46,6 +46,12 @@ Instance attributes are:
A :class:`RPCCache` instance to compute the cache duration for the answer.
.. attribute:: RPC.timeout
The timeout in second for the call.
``0`` or ``None`` means no timeout is applied.
Default is ``None``.
RPCCache
--------
+7
View File
@@ -271,6 +271,13 @@ The maximum size in bytes of an authenticated request (zero means no limit).
Default: 2GB
timeout
~~~~~~~
The timeout in seconds before aborting requests that have their execution time
depending on the parameters.
Default: ``60``
cache
-----
+3 -1
View File
@@ -12,7 +12,8 @@ from trytond.config import config
__all__ = [
'name', 'Database', 'TableHandler',
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError']
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError',
'DatabaseTimeoutError']
name = urllib.parse.urlparse(config.get('database', 'uri', default='')).scheme
@@ -34,4 +35,5 @@ Database = _module.Database
DatabaseIntegrityError = _module.DatabaseIntegrityError
DatabaseDataError = _module.DatabaseDataError
DatabaseOperationalError = _module.DatabaseOperationalError
DatabaseTimeoutError = _module.DatabaseTimeoutError
TableHandler = _module.TableHandler
+3 -1
View File
@@ -6,6 +6,7 @@ from sql import For
DatabaseIntegrityError = None
DatabaseOperationalError = None
DatabaseTimeoutError = None
SQLType = namedtuple('SQLType', 'base type')
@@ -26,7 +27,8 @@ class DatabaseInterface(object):
def connect(self):
raise NotImplementedError
def get_connection(self, autocommit=False, readonly=False):
def get_connection(
self, autocommit=False, readonly=False, statement_timeout=None):
raise NotImplementedError
def put_connection(self, connection, close=False):
@@ -3,9 +3,10 @@
from .database import (
Database, DatabaseDataError, DatabaseIntegrityError,
DatabaseOperationalError)
DatabaseOperationalError, DatabaseTimeoutError)
from .table import TableHandler
__all__ = [
Database, TableHandler,
DatabaseIntegrityError, DatabaseDataError, DatabaseOperationalError]
DatabaseIntegrityError, DatabaseDataError, DatabaseOperationalError,
DatabaseTimeoutError]
+13 -4
View File
@@ -26,6 +26,7 @@ from psycopg2 import DataError as DatabaseDataError
from psycopg2 import IntegrityError as DatabaseIntegrityError
from psycopg2 import OperationalError as DatabaseOperationalError
from psycopg2 import ProgrammingError
from psycopg2.errors import QueryCanceled as DatabaseTimeoutError
from psycopg2.extras import register_default_json, register_default_jsonb
from sql import Cast, Flavor, For, Table
from sql.conditionals import Coalesce
@@ -38,7 +39,8 @@ from trytond.tools.gevent import is_gevent_monkey_patched
__all__ = [
'Database',
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError']
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError',
'DatabaseTimeoutError']
logger = logging.getLogger(__name__)
@@ -254,7 +256,8 @@ class Database(DatabaseInterface):
def connect(self):
return self
def get_connection(self, autocommit=False, readonly=False):
def get_connection(
self, autocommit=False, readonly=False, statement_timeout=None):
retry = max(config.getint('database', 'retry'), _maxconn)
for count in range(retry, -1, -1):
try:
@@ -274,8 +277,13 @@ class Database(DatabaseInterface):
isolation_level=ISOLATION_LEVEL_REPEATABLE_READ,
readonly=readonly,
autocommit=autocommit)
# Detect disconnection
conn.cursor().execute('SELECT 1')
with conn.cursor() as cur:
if statement_timeout:
cur.execute('SET statement_timeout=%s' %
(statement_timeout * 1000))
else:
# Detect disconnection
cur.execute('SELECT 1')
except DatabaseOperationalError:
self._connpool.putconn(conn, close=True)
continue
@@ -283,6 +291,7 @@ class Database(DatabaseInterface):
return conn
def put_connection(self, connection, close=False):
connection.reset()
self._connpool.putconn(connection, close=close)
def close(self):
+3 -2
View File
@@ -3,9 +3,10 @@
from .database import (
Database, DatabaseDataError, DatabaseIntegrityError,
DatabaseOperationalError)
DatabaseOperationalError, DatabaseTimeoutError)
from .table import TableHandler
__all__ = [
Database, TableHandler,
DatabaseIntegrityError, DatabaseDataError, DatabaseOperationalError]
DatabaseIntegrityError, DatabaseDataError, DatabaseOperationalError,
DatabaseTimeoutError]
+8 -2
View File
@@ -29,7 +29,8 @@ from trytond.transaction import Transaction
__all__ = [
'Database',
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError']
'DatabaseIntegrityError', 'DatabaseDataError', 'DatabaseOperationalError',
'DatabaseTimeoutError']
logger = logging.getLogger(__name__)
_default_name = config.get('database', 'default_name', default=':memory:')
@@ -39,6 +40,10 @@ class DatabaseDataError(DatabaseError):
pass
class DatabaseTimeoutError(Exception):
pass
class SQLiteExtract(Function):
__slots__ = ()
_function = 'EXTRACT'
@@ -445,7 +450,8 @@ class Database(DatabaseInterface):
db_uri = urllib.parse.urlunparse(db_uri)
return db_uri.replace('sqlite', 'file', 1)
def get_connection(self, autocommit=False, readonly=False):
def get_connection(
self, autocommit=False, readonly=False, statement_timeout=None):
if self._conn is None:
self.connect()
if autocommit:
+1
View File
@@ -70,6 +70,7 @@ class TrytonConfigParser(configparser.ConfigParser):
self.set('request', 'max_size', str(2 * 1024 * 1024))
self.set('request', 'max_size_authenticated',
str(2 * 1024 * 1024 * 1024))
self.set('request', 'timeout', str(60))
self.add_section('cache')
self.set('cache', 'transaction', '10')
self.set('cache', 'model', '200')
+3 -1
View File
@@ -13,6 +13,7 @@ from sql.conditionals import Case
from sql.operators import Equal
from trytond.cache import Cache
from trytond.config import config
from trytond.i18n import gettext
from trytond.model import (
DeactivableMixin, EvalEnvironment, Exclude, Index, ModelSingleton,
@@ -31,6 +32,7 @@ from trytond.wizard import Button, StateAction, StateView, Wizard
from .resource import ResourceAccessMixin
logger = logging.getLogger(__name__)
_request_timeout = config.getint('request', 'timeout', default=0)
class ConditionError(ValidationError):
@@ -77,7 +79,7 @@ class Model(ModelSQL, ModelView):
'list_history': RPC(),
'get_notification': RPC(),
'get_names': RPC(),
'global_search': RPC(),
'global_search': RPC(timeout=_request_timeout),
})
@classmethod
+10 -5
View File
@@ -39,6 +39,7 @@ _cache_count_timeout = config.getint(
'cache', 'count_timeout', default=60 * 60 * 24)
_cache_count_clear = config.getint(
'cache', 'count_clear', default=1000)
_request_timeout = config.getint('request', 'timeout', default=0)
def local_cache(Model, transaction=None):
@@ -136,11 +137,15 @@ class ModelStorage(Model):
'delete': RPC(readonly=False, instantiate=0),
'copy': RPC(readonly=False, instantiate=0, unique=False,
result=lambda r: list(map(int, r))),
'search': RPC(result=lambda r: list(map(int, r))),
'search_count': RPC(),
'search_read': RPC(),
'resources': RPC(instantiate=0, unique=False),
'export_data_domain': RPC(),
'search': RPC(
result=lambda r: list(map(int, r)),
timeout=_request_timeout),
'search_count': RPC(timeout=_request_timeout),
'search_read': RPC(timeout=_request_timeout),
'resources': RPC(
instantiate=0, unique=False,
timeout=_request_timeout),
'export_data_domain': RPC(timeout=_request_timeout),
'export_data': RPC(instantiate=0, unique=False),
'import_data': RPC(readonly=False),
})
+5 -1
View File
@@ -184,7 +184,8 @@ def _dispatch(request, pool, *args, **kwargs):
if count:
time.sleep(0.02 * (retry - count))
with Transaction().start(
pool.database_name, user, readonly=rpc.readonly,
pool.database_name, user,
readonly=rpc.readonly, timeout=rpc.timeout,
**transaction_extras) as transaction:
try:
c_args, c_kwargs, transaction.context, transaction.timestamp \
@@ -207,6 +208,9 @@ def _dispatch(request, pool, *args, **kwargs):
transaction.tasks.clear()
e.fix(transaction_extras)
continue
except backend.DatabaseTimeoutError:
logger.warning(log_message, *log_args, exc_info=True)
abort(HTTPStatus.REQUEST_TIMEOUT)
except backend.DatabaseOperationalError:
if count < retry and not rpc.readonly:
transaction.rollback()
+4 -2
View File
@@ -21,11 +21,12 @@ class RPC(object):
__slots__ = (
'readonly', 'instantiate', 'decorator', 'result',
'check_access', 'fresh_session', 'unique', 'cache')
'check_access', 'fresh_session', 'unique', 'cache', 'timeout')
def __init__(
self, readonly=True, instantiate=None, decorator=None, result=None,
check_access=True, fresh_session=False, unique=True, cache=None):
check_access=True, fresh_session=False, unique=True, cache=None,
timeout=None):
self.readonly = readonly
self.instantiate = instantiate
self.decorator = decorator
@@ -40,6 +41,7 @@ class RPC(object):
if not isinstance(cache, RPCCache):
cache = RPCCache(**cache)
self.cache = cache
self.timeout = timeout
def convert(self, obj, *args, **kwargs):
args = list(args)
+24
View File
@@ -3,6 +3,7 @@
import unittest
from unittest.mock import Mock
from trytond import backend
from trytond.tests.test_tryton import CONTEXT, DB_NAME, USER, activate_module
from trytond.transaction import Transaction
@@ -121,3 +122,26 @@ class TransactionTestCase(unittest.TestCase):
dm.tpc_vote.assert_not_called()
dm.tpc_abort.assert_called_once_with(transaction)
dm.tpc_finish.assert_not_called()
@unittest.skipUnless(backend.name == 'postgresql', "Test pg_settings")
def test_postgresl_statement_timeout(self):
get_timeout = (
"SELECT setting FROM pg_settings "
"WHERE name='statement_timeout'")
with Transaction().start(DB_NAME, USER) as transaction:
cursor = transaction.connection.cursor()
cursor.execute(get_timeout)
self.assertEqual('0', cursor.fetchone()[0])
with Transaction().start(DB_NAME, USER, timeout=1) as transaction:
cursor = transaction.connection.cursor()
cursor.execute(get_timeout)
self.assertEqual('1000', cursor.fetchone()[0])
@unittest.skipUnless(backend.name == 'postgresql', "Use pg_sleep")
def test_postgresql_statement_timeout_exception(self):
with self.assertRaises(backend.DatabaseTimeoutError):
with Transaction().start(DB_NAME, USER, timeout=1) as transaction:
cursor = transaction.connection.cursor()
cursor.execute("SELECT pg_sleep(2)")
+2 -2
View File
@@ -159,7 +159,7 @@ class Transaction(object):
return cache
def start(self, database_name, user, readonly=False, context=None,
close=False, autocommit=False, **extras):
close=False, autocommit=False, timeout=None, **extras):
'''
Start transaction
'''
@@ -197,7 +197,7 @@ class Transaction(object):
if count:
time.sleep(0.002 * (_retry - count))
self.connection = database.get_connection(readonly=readonly,
autocommit=autocommit)
autocommit=autocommit, statement_timeout=timeout)
try:
lock_tables = extras.get('_lock_tables', [])
for table in lock_tables: