diff --git a/sao/src/common.js b/sao/src/common.js index 74d94edf96..1b3bafb36c 100644 --- a/sao/src/common.js +++ b/sao/src/common.js @@ -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; diff --git a/sao/src/model.js b/sao/src/model.js index 590034770c..13915bd59f 100644 --- a/sao/src/model.js +++ b/sao/src/model.js @@ -1226,7 +1226,7 @@ try { result = this.model.execute( 'autocomplete_' + fieldname, [values], this.get_context(), - false); + false, false); } catch (e) { result = []; } diff --git a/sao/src/rpc.js b/sao/src/rpc.js index ea5a51ae44..d5852209a7 100644 --- a/sao/src/rpc.js +++ b/sao/src/rpc.js @@ -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.'), diff --git a/sao/src/sao.js b/sao/src/sao.js index 9fe6c13d35..9e5807824e 100644 --- a/sao/src/sao.js +++ b/sao/src/sao.js @@ -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) { diff --git a/sao/src/screen.js b/sao/src/screen.js index 3e380ce3a6..99093501f9 100644 --- a/sao/src/screen.js +++ b/sao/src/screen.js @@ -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]); diff --git a/sao/src/view/form.js b/sao/src/view/form.js index 7b639fd1a3..58b24d4721 100644 --- a/sao/src/view/form.js +++ b/sao/src/view/form.js @@ -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); diff --git a/tryton/tryton/common/common.py b/tryton/tryton/common/common.py index 5c7a53e760..1fcb9d4576 100644 --- a/tryton/tryton/common/common.py +++ b/tryton/tryton/common/common.py @@ -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.'), diff --git a/tryton/tryton/common/selection.py b/tryton/tryton/common/selection.py index a7e420165b..3ad456a15c 100644 --- a/tryton/tryton/common/selection.py +++ b/tryton/tryton/common/selection.py @@ -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 diff --git a/tryton/tryton/gui/main.py b/tryton/tryton/gui/main.py index 436fff2b4f..1b979831b0 100644 --- a/tryton/tryton/gui/main.py +++ b/tryton/tryton/gui/main.py @@ -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): diff --git a/tryton/tryton/gui/window/view_form/model/record.py b/tryton/tryton/gui/window/view_form/model/record.py index 2888cd8d19..2b58554d25 100644 --- a/tryton/tryton/gui/window/view_form/model/record.py +++ b/tryton/tryton/gui/window/view_form/model/record.py @@ -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 diff --git a/tryton/tryton/gui/window/view_form/screen/screen.py b/tryton/tryton/gui/window/view_form/screen/screen.py index 6a0546841b..890f43d9ec 100644 --- a/tryton/tryton/gui/window/view_form/screen/screen.py +++ b/tryton/tryton/gui/window/view_form/screen/screen.py @@ -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) diff --git a/tryton/tryton/gui/window/view_form/view/form_gtk/state_widget.py b/tryton/tryton/gui/window/view_form/view/form_gtk/state_widget.py index 0df55668a7..9657269d36 100644 --- a/tryton/tryton/gui/window/view_form/view/form_gtk/state_widget.py +++ b/tryton/tryton/gui/window/view_form/view/form_gtk/state_widget.py @@ -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) diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index e35e89422a..b009e8de89 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Add timeout parameter on RPC definitions Version 7.0.0 - 2023-10-30 -------------------------- diff --git a/trytond/doc/ref/rpc.rst b/trytond/doc/ref/rpc.rst index d7dc7c8277..00dd3b0190 100644 --- a/trytond/doc/ref/rpc.rst +++ b/trytond/doc/ref/rpc.rst @@ -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 -------- diff --git a/trytond/doc/topics/configuration.rst b/trytond/doc/topics/configuration.rst index 5c93712f69..de51013c7c 100644 --- a/trytond/doc/topics/configuration.rst +++ b/trytond/doc/topics/configuration.rst @@ -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 ----- diff --git a/trytond/trytond/backend/__init__.py b/trytond/trytond/backend/__init__.py index 7218d63567..6dde7d5b99 100644 --- a/trytond/trytond/backend/__init__.py +++ b/trytond/trytond/backend/__init__.py @@ -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 diff --git a/trytond/trytond/backend/database.py b/trytond/trytond/backend/database.py index 4da335ef9a..9915bd76c3 100644 --- a/trytond/trytond/backend/database.py +++ b/trytond/trytond/backend/database.py @@ -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): diff --git a/trytond/trytond/backend/postgresql/__init__.py b/trytond/trytond/backend/postgresql/__init__.py index 98083d06a7..05a01ca5be 100644 --- a/trytond/trytond/backend/postgresql/__init__.py +++ b/trytond/trytond/backend/postgresql/__init__.py @@ -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] diff --git a/trytond/trytond/backend/postgresql/database.py b/trytond/trytond/backend/postgresql/database.py index 7ffdd04ab0..5cb21347d4 100644 --- a/trytond/trytond/backend/postgresql/database.py +++ b/trytond/trytond/backend/postgresql/database.py @@ -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): diff --git a/trytond/trytond/backend/sqlite/__init__.py b/trytond/trytond/backend/sqlite/__init__.py index 98083d06a7..05a01ca5be 100644 --- a/trytond/trytond/backend/sqlite/__init__.py +++ b/trytond/trytond/backend/sqlite/__init__.py @@ -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] diff --git a/trytond/trytond/backend/sqlite/database.py b/trytond/trytond/backend/sqlite/database.py index 51fd17e616..a484d158da 100644 --- a/trytond/trytond/backend/sqlite/database.py +++ b/trytond/trytond/backend/sqlite/database.py @@ -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: diff --git a/trytond/trytond/config.py b/trytond/trytond/config.py index 3aebf6c6c9..e6e9ab23d9 100644 --- a/trytond/trytond/config.py +++ b/trytond/trytond/config.py @@ -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') diff --git a/trytond/trytond/ir/model.py b/trytond/trytond/ir/model.py index 2e7ecc0320..d8162f7d0e 100644 --- a/trytond/trytond/ir/model.py +++ b/trytond/trytond/ir/model.py @@ -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 diff --git a/trytond/trytond/model/modelstorage.py b/trytond/trytond/model/modelstorage.py index 048828fe67..be7bcbd13c 100644 --- a/trytond/trytond/model/modelstorage.py +++ b/trytond/trytond/model/modelstorage.py @@ -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), }) diff --git a/trytond/trytond/protocols/dispatcher.py b/trytond/trytond/protocols/dispatcher.py index d750ba0558..9185cf7357 100644 --- a/trytond/trytond/protocols/dispatcher.py +++ b/trytond/trytond/protocols/dispatcher.py @@ -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() diff --git a/trytond/trytond/rpc.py b/trytond/trytond/rpc.py index c1bd427e9d..727f8f1f91 100644 --- a/trytond/trytond/rpc.py +++ b/trytond/trytond/rpc.py @@ -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) diff --git a/trytond/trytond/tests/test_transaction.py b/trytond/trytond/tests/test_transaction.py index 24d004fd8f..7b63464b44 100644 --- a/trytond/trytond/tests/test_transaction.py +++ b/trytond/trytond/tests/test_transaction.py @@ -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)") diff --git a/trytond/trytond/transaction.py b/trytond/trytond/transaction.py index 993c391cbf..ed179c7316 100644 --- a/trytond/trytond/transaction.py +++ b/trytond/trytond/transaction.py @@ -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: