details: https://code.tryton.org/tryton/commit/a8e1b755689f
branch: 7.4
user: Cédric Krier <[email protected]>
date: Fri Oct 03 16:18:42 2025 +0200
description:
Lock records on transaction start
Same as c8771e45b861 but when locking only records instead of the table.
Closes #13943
(grafted from 00790a58e6a364c46dd7178de02bd6842e6dc0c6)
diffstat:
trytond/CHANGELOG | 1 +
trytond/doc/ref/backend.rst | 4 ++
trytond/doc/ref/transaction.rst | 5 +++
trytond/trytond/backend/database.py | 4 ++
trytond/trytond/backend/postgresql/database.py | 14 ++++++++-
trytond/trytond/backend/sqlite/database.py | 7 ++++-
trytond/trytond/commandline.py | 10 ++++++
trytond/trytond/console.py | 3 +-
trytond/trytond/model/modelsql.py | 19 ++++--------
trytond/trytond/tests/test_modelsql.py | 38 ++++++++++++++++++++++---
trytond/trytond/transaction.py | 22 +++++++++++++++
11 files changed, 106 insertions(+), 21 deletions(-)
diffs (282 lines):
diff -r be1689e141eb -r a8e1b755689f trytond/CHANGELOG
--- a/trytond/CHANGELOG Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/CHANGELOG Fri Oct 03 16:18:42 2025 +0200
@@ -1,3 +1,4 @@
+* Lock records on transaction start
Version 7.4.18 - 2025-10-02
---------------------------
diff -r be1689e141eb -r a8e1b755689f trytond/doc/ref/backend.rst
--- a/trytond/doc/ref/backend.rst Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/doc/ref/backend.rst Fri Oct 03 16:18:42 2025 +0200
@@ -87,6 +87,10 @@
Lock the ``table`` using the ``connection``.
+.. classmethod:: Database.lock_records(connection, table, ids)
+
+ Lock the record ``ids`` of the ``table`` using the ``connection``.
+
.. method:: Database.lock_id(id[, timeout])
Return the SQL expression to lock the ``id``.
diff -r be1689e141eb -r a8e1b755689f trytond/doc/ref/transaction.rst
--- a/trytond/doc/ref/transaction.rst Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/doc/ref/transaction.rst Fri Oct 03 16:18:42 2025 +0200
@@ -111,6 +111,11 @@
Raise a :exc:`TransactionError` to retry the transaction if the table has
not been locked at the start.
+.. method:: Transaction.lock_records(table, ids)
+
+ Raise a :exc:`TransactionError` to retry the transaction if the ``ids`` of
+ the ``table`` has not been locked at the start.
+
.. method:: Transaction.set_current_transaction(transaction)
Add a specific ``transaction`` on the top of the transaction stack.
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/backend/database.py
--- a/trytond/trytond/backend/database.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/backend/database.py Fri Oct 03 16:18:42 2025 +0200
@@ -73,6 +73,10 @@
def lock(cls, connection, table):
raise NotImplementedError
+ @classmethod
+ def lock_records(cls, connection, table, ids):
+ raise NotImplementedError
+
def lock_id(self, id, timeout=None):
raise NotImplementedError
diff -r be1689e141eb -r a8e1b755689f
trytond/trytond/backend/postgresql/database.py
--- a/trytond/trytond/backend/postgresql/database.py Thu Sep 18 09:13:01
2025 +0200
+++ b/trytond/trytond/backend/postgresql/database.py Fri Oct 03 16:18:42
2025 +0200
@@ -39,6 +39,7 @@
from trytond.backend.database import DatabaseInterface, SQLType
from trytond.config import config, parse_uri
from trytond.sql.operators import RangeOperator
+from trytond.tools import grouped_slice, reduce_ids
from trytond.tools.gevent import is_gevent_monkey_patched
from .table import index_method
@@ -470,11 +471,22 @@
cursor.execute(*from_item.select(Count(Literal('*'))))
return cursor.fetchone()[0]
- def lock(self, connection, table):
+ @classmethod
+ def lock(cls, connection, table):
cursor = connection.cursor()
cursor.execute(SQL('LOCK {} IN EXCLUSIVE MODE NOWAIT').format(
Identifier(table)))
+ @classmethod
+ def lock_records(cls, connection, table, ids):
+ table = Table(table)
+ cursor = connection.cursor()
+ for sub_ids in grouped_slice(ids):
+ where = reduce_ids(table.id, sub_ids)
+ query = table.select(
+ Literal(1), where=where, for_=For('UPDATE', nowait=True))
+ cursor.execute(*query)
+
def lock_id(self, id, timeout=None):
if not timeout:
return TryAdvisoryLock(id)
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/backend/sqlite/database.py
--- a/trytond/trytond/backend/sqlite/database.py Thu Sep 18 09:13:01
2025 +0200
+++ b/trytond/trytond/backend/sqlite/database.py Fri Oct 03 16:18:42
2025 +0200
@@ -601,7 +601,12 @@
cursor.execute(*table.select(Count(Literal('*'))))
return cursor.fetchone()[0]
- def lock(self, connection, table):
+ @classmethod
+ def lock(cls, connection, table):
+ pass
+
+ @classmethod
+ def lock_records(cls, connection, table, ids):
pass
def lock_id(self, id, timeout=None):
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/commandline.py
--- a/trytond/trytond/commandline.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/commandline.py Fri Oct 03 16:18:42 2025 +0200
@@ -157,6 +157,12 @@
return parser
+def _convert_records(s):
+ table, ids = s.split('=', 1)
+ ids = list(map(int, ids.split(',')))
+ return table, ids
+
+
def get_parser_console():
parser = get_base_parser()
parser.add_argument(
@@ -170,6 +176,10 @@
parser.add_argument(
"--lock-table", dest="lock_tables", nargs='+', default=[],
metavar='TABLE', help="lock tables")
+ parser.register('type', 'records', _convert_records)
+ parser.add_argument(
+ "--lock-records", dest="lock_records", type='records', nargs='+',
+ default=[], metavar='TABLE=id,id', help="lock record ids from TABLE")
parser.epilog = "To store changes, `transaction.commit()` must be called."
return parser
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/console.py
--- a/trytond/trytond/console.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/console.py Fri Oct 03 16:18:42 2025 +0200
@@ -47,7 +47,8 @@
with Transaction().start(
db_name, 0, readonly=options.readonly,
- _lock_tables=options.lock_tables) as transaction:
+ _lock_tables=options.lock_tables,
+ _lock_records=dict(options.lock_records)) as transaction:
local = {
'pool': pool,
'transaction': transaction,
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/model/modelsql.py
--- a/trytond/trytond/model/modelsql.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/model/modelsql.py Fri Oct 03 16:18:42 2025 +0200
@@ -6,8 +6,8 @@
from itertools import chain, groupby, islice, product, repeat
from sql import (
- Asc, Column, Desc, Expression, For, Literal, Null, NullsFirst, NullsLast,
- Table, Union, Window, With)
+ Asc, Column, Desc, Expression, Literal, Null, NullsFirst, NullsLast, Table,
+ Union, Window, With)
from sql.aggregate import Count, Max
from sql.conditionals import Coalesce
from sql.functions import CurrentTimestamp, Extract, RowNumber, Substring
@@ -2282,17 +2282,10 @@
@dualmethod
def lock(cls, records=None):
transaction = Transaction()
- database = transaction.database
- connection = transaction.connection
- table = cls.__table__()
-
- if records is not None and database.has_select_for():
- for sub_records in grouped_slice(records):
- where = reduce_ids(table.id, sub_records)
- query = table.select(
- Literal(1), where=where, for_=For('UPDATE', nowait=True))
- with connection.cursor() as cursor:
- cursor.execute(*query)
+ if records is not None:
+ new_ids = transaction.create_records[cls.__name__]
+ ids = [id_ for id_ in map(int, records) if id_ not in new_ids]
+ transaction.lock_records(cls._table, ids)
else:
transaction.lock_table(cls._table)
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/tests/test_modelsql.py
--- a/trytond/trytond/tests/test_modelsql.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/tests/test_modelsql.py Fri Oct 03 16:18:42 2025 +0200
@@ -922,13 +922,28 @@
record_id = Model.create([{}])[0].id
transaction.commit()
- with transaction.new_transaction():
- record = Model(record_id)
- record.lock()
- with transaction.new_transaction():
+ args1 = {}
+ while True:
+ with transaction.new_transaction(**args1):
record = Model(record_id)
- with self.assertRaises(backend.DatabaseOperationalError):
+ try:
record.lock()
+ except TransactionError as e:
+ e.fix(args1)
+ continue
+ with self.assertRaises(
+ backend.DatabaseOperationalError):
+ args2 = {}
+ while True:
+ with transaction.new_transaction(**args2):
+ record = Model(record_id)
+ try:
+ record.lock()
+ except TransactionError as e:
+ e.fix(args2)
+ continue
+ break
+ break
@unittest.skipIf(backend.name == 'sqlite',
'SQLite does not have lock at table level but on file')
@@ -961,6 +976,19 @@
break
@with_transaction()
+ def test_record_lock_with_table_lock(self):
+ "Test not locking record when table is locked"
+ pool = Pool()
+ Model = pool.get('test.modelsql.lock')
+ transaction = Transaction()
+ record_id = Model.create([{}])[0].id
+ transaction.commit()
+
+ with transaction.new_transaction(_lock_tables=[Model._table]):
+ record = Model(record_id)
+ record.lock()
+
+ @with_transaction()
def test_search_or_to_union(self):
"""
Test searching for 'OR'-ed domain
diff -r be1689e141eb -r a8e1b755689f trytond/trytond/transaction.py
--- a/trytond/trytond/transaction.py Thu Sep 18 09:13:01 2025 +0200
+++ b/trytond/trytond/transaction.py Fri Oct 03 16:18:42 2025 +0200
@@ -38,6 +38,18 @@
extras.setdefault('_lock_tables', []).append(self._table)
+class _TransactionLockRecordsError(TransactionError):
+ def __init__(self, table, ids):
+ super().__init__()
+ self._table = table
+ self._ids = ids
+
+ def fix(self, extras):
+ super().fix(extras)
+ extras.setdefault('_lock_records', {}).setdefault(
+ self._table, []).extend(self._ids)
+
+
def record_cache_size(transaction):
return transaction.context.get('_record_cache_size', _cache_record)
@@ -203,6 +215,11 @@
for table in lock_tables:
self.database.lock(self.connection, table)
self._locked_tables = set(lock_tables)
+ locked_records = defaultdict(set)
+ for table, ids in extras.get('_lock_records', {}).items():
+ self.database.lock_records(self.connection, table, ids)
+ locked_records[table].update(ids)
+ self._locked_records = locked_records
except backend.DatabaseOperationalError:
if count < _retry:
self.connection.rollback()
@@ -297,6 +314,11 @@
if table not in self._locked_tables:
raise _TransactionLockError(table)
+ def lock_records(self, table, ids):
+ if table not in self._locked_tables:
+ if missing := (set(ids) - self._locked_records[table]):
+ raise _TransactionLockRecordsError(table, missing)
+
def set_current_transaction(self, transaction):
self._local.transactions.append(transaction)
return transaction