Skip to content

Commit b25a88e

Browse files
authored
Add 'add_version_condition' arg (#1177)
Add a flag for controlling whether `Model.save`, `Model.update` and `Model.delete` add a condition that the persisted version is the same as the in-memory one, defaulting to `True` (the "safe" behavior).
1 parent 71a0873 commit b25a88e

8 files changed

+358
-255
lines changed

docs/optimistic_locking.rst

+53-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.. _optimistic_locking:
2+
13
==================
24
Optimistic Locking
35
==================
@@ -18,7 +20,16 @@ See also:
1820
Version Attribute
1921
-----------------
2022

21-
To enable optimistic locking for a table simply add a ``VersionAttribute`` to your model definition.
23+
To enable optimistic locking for a table, add a ``VersionAttribute`` to your model definition. The presence of this
24+
attribute will change the model's behaviors:
25+
26+
* :meth:`~pynamodb.models.Model.save` and :meth:`~pynamodb.models.Model.update` would increment the version attribute
27+
every time the model is persisted. This allows concurrent updates not to overwrite each other, at the expense
28+
of the latter update failing.
29+
* :meth:`~pynamodb.models.Model.save`, :meth:`~pynamodb.models.Model.update`
30+
and :meth:`~pynamodb.models.Model.delete` would fail if they are the "latter update" (by adding to the update's
31+
:ref:`conditions <conditional_operations>`). This behavior is optional since sometimes a more granular approach
32+
can be desired (see :ref:`optimistic_locking_version_condition`).
2233

2334
.. code-block:: python
2435
@@ -86,7 +97,7 @@ These operations will fail if the local object is out-of-date.
8697
except TransactWriteError as e:
8798
assert isinstance(e.cause, ClientError)
8899
assert e.cause_response_code == "TransactionCanceledException"
89-
assert "ConditionalCheckFailed" in e.cause_response_message
100+
assert any(r.code == "ConditionalCheckFailed" for r in e.cancellation_reasons)
90101
else:
91102
raise AssertionError("The version attribute conditional check should have failed.")
92103
@@ -107,6 +118,46 @@ These operations will fail if the local object is out-of-date.
107118
with assert_condition_check_fails():
108119
office.delete()
109120
121+
122+
.. _optimistic_locking_version_condition:
123+
124+
Conditioning on the version
125+
---------------------------
126+
127+
To have :meth:`~pynamodb.models.Model.save`, :meth:`~pynamodb.models.Model.update` or :meth:`~pynamodb.models.Model.delete`
128+
execute even if the item was changed by someone else, pass the ``add_version_condition=False`` parameter.
129+
In this mode, updates would perform unconditionally but would still increment the version:
130+
in other words, you could make other updates fail, but your update will succeed.
131+
132+
Done indiscriminately, this would be unsafe, but can be useful in certain scenarios:
133+
134+
#. For ``save``, this is almost always unsafe and undesirable.
135+
#. For ``update``, use it when updating attributes for which a "last write wins" approach is acceptable,
136+
or if you're otherwise conditioning the update in a way that is more domain-specific.
137+
#. For ``delete``, use it to delete the item regardless of its contents.
138+
139+
For example, if your ``save`` operation experiences frequent "ConditionalCheckFailedException" failures,
140+
rewrite your code to call ``update`` with individual attributes while passing :code:`add_version_condition=False`.
141+
By disabling the version condition, you could no longer rely on the checks you've done prior to the modification (due to
142+
what is known as the "time-of-check to time-of-use" problem). Therefore, consider adding domain-specific conditions
143+
to ensure the item in the table is in the expected state prior to the update.
144+
145+
For example, let's consider a hotel room-booking service with the conventional constraint that only one person
146+
can book a room at a time. We can switch from a ``save`` to an ``update`` by specifying the individual attributes
147+
and rewriting the `if` statement as a condition:
148+
149+
.. code-block:: diff
150+
151+
- if room.booked_by:
152+
- raise Exception("Room is already booked")
153+
- room.booked_by = user_id
154+
- room.save()
155+
+ room.update(
156+
+ actions=[Room.booked_by.set(user_id)],
157+
+ condition=Room.booked_by.does_not_exist(),
158+
+ add_version_condition=False,
159+
+ )
160+
110161
Transactions
111162
------------
112163

docs/release_notes.rst

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
Release Notes
44
=============
55

6+
v5.5.0
7+
----------
8+
* :meth:`~pynamodb.models.Model.save`, :meth:`~pynamodb.models.Model.update`, :meth:`~pynamodb.models.Model.delete_item`,
9+
and :meth:`~pynamodb.models.Model.delete` now accept a ``add_version_condition`` parameter.
10+
See :ref:`optimistic_locking_version_condition` for more details.
11+
612
v5.4.1
713
----------
814
* Use model's AWS credentials in threads (#1164)

pynamodb/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"""
88
__author__ = 'Jharrod LaFon'
99
__license__ = 'MIT'
10-
__version__ = '5.4.1'
10+
__version__ = '5.5.0'

pynamodb/attributes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ def deserialize(self, value):
624624

625625
class VersionAttribute(NumberAttribute):
626626
"""
627-
A version attribute
627+
A number attribute that implements :ref:`optimistic locking <optimistic_locking>`.
628628
"""
629629
null = True
630630

pynamodb/models.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -402,26 +402,35 @@ def __repr__(self) -> str:
402402
msg = "{}<{}>".format(self.Meta.table_name, hash_key)
403403
return msg
404404

405-
def delete(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Any:
405+
def delete(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default,
406+
*, add_version_condition: bool = True) -> Any:
406407
"""
407-
Deletes this object from dynamodb
408+
Deletes this object from DynamoDB.
408409
410+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
411+
specifies whether the item should only be deleted if its current version matches the expected one.
412+
Set to `False` for a 'delete anyway' strategy.
409413
:raises pynamodb.exceptions.DeleteError: If the record can not be deleted
410414
"""
411415
hk_value, rk_value = self._get_hash_range_key_serialized_values()
416+
412417
version_condition = self._handle_version_attribute()
413-
if version_condition is not None:
418+
if add_version_condition and version_condition is not None:
414419
condition &= version_condition
415420

416421
return self._get_connection().delete_item(hk_value, range_key=rk_value, condition=condition, settings=settings)
417422

418-
def update(self, actions: List[Action], condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Any:
423+
def update(self, actions: List[Action], condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default, *, add_version_condition: bool = True) -> Any:
419424
"""
420425
Updates an item using the UpdateItem operation.
421426
422427
:param actions: a list of Action updates to apply
423428
:param condition: an optional Condition on which to update
424429
:param settings: per-operation settings
430+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
431+
specifies whether only to update if the version matches the model that is currently loaded.
432+
Set to `False` for a 'last write wins' strategy.
433+
Regardless, the version will always be incremented to prevent "rollbacks" by concurrent :meth:`save` calls.
425434
:raises ModelInstance.DoesNotExist: if the object to be updated does not exist
426435
:raises pynamodb.exceptions.UpdateError: if the `condition` is not met
427436
"""
@@ -430,7 +439,7 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, s
430439

431440
hk_value, rk_value = self._get_hash_range_key_serialized_values()
432441
version_condition = self._handle_version_attribute(actions=actions)
433-
if version_condition is not None:
442+
if add_version_condition and version_condition is not None:
434443
condition &= version_condition
435444

436445
data = self._get_connection().update_item(hk_value, range_key=rk_value, return_values=ALL_NEW, condition=condition, actions=actions, settings=settings)
@@ -441,11 +450,11 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, s
441450
self.deserialize(item_data)
442451
return data
443452

444-
def save(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Dict[str, Any]:
453+
def save(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default, *, add_version_condition: bool = True) -> Dict[str, Any]:
445454
"""
446455
Save this object to dynamodb
447456
"""
448-
args, kwargs = self._get_save_args(condition=condition)
457+
args, kwargs = self._get_save_args(condition=condition, add_version_condition=add_version_condition)
449458
kwargs['settings'] = settings
450459
data = self._get_connection().put_item(*args, **kwargs)
451460
self.update_local_version_attribute()
@@ -474,11 +483,13 @@ def get_update_kwargs_from_instance(
474483
actions: List[Action],
475484
condition: Optional[Condition] = None,
476485
return_values_on_condition_failure: Optional[str] = None,
486+
*,
487+
add_version_condition: bool = True,
477488
) -> Dict[str, Any]:
478489
hk_value, rk_value = self._get_hash_range_key_serialized_values()
479490

480491
version_condition = self._handle_version_attribute(actions=actions)
481-
if version_condition is not None:
492+
if add_version_condition and version_condition is not None:
482493
condition &= version_condition
483494

484495
return self._get_connection().get_operation_kwargs(hk_value, range_key=rk_value, key=KEY, actions=actions, condition=condition, return_values_on_condition_failure=return_values_on_condition_failure)
@@ -487,11 +498,13 @@ def get_delete_kwargs_from_instance(
487498
self,
488499
condition: Optional[Condition] = None,
489500
return_values_on_condition_failure: Optional[str] = None,
501+
*,
502+
add_version_condition: bool = True,
490503
) -> Dict[str, Any]:
491504
hk_value, rk_value = self._get_hash_range_key_serialized_values()
492505

493506
version_condition = self._handle_version_attribute()
494-
if version_condition is not None:
507+
if add_version_condition and version_condition is not None:
495508
condition &= version_condition
496509

497510
return self._get_connection().get_operation_kwargs(hk_value, range_key=rk_value, key=KEY, condition=condition, return_values_on_condition_failure=return_values_on_condition_failure)
@@ -900,14 +913,17 @@ def _get_schema(cls) -> Dict[str, Any]:
900913
})
901914
return schema
902915

903-
def _get_save_args(self, null_check: bool = True, condition: Optional[Condition] = None) -> Tuple[Iterable[Any], Dict[str, Any]]:
916+
def _get_save_args(self, null_check: bool = True, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Tuple[Iterable[Any], Dict[str, Any]]:
904917
"""
905918
Gets the proper *args, **kwargs for saving and retrieving this object
906919
907920
This is used for serializing items to be saved, or for serializing just the keys.
908921
909922
:param null_check: If True, then attributes are checked for null.
910923
:param condition: If set, a condition
924+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
925+
specifies whether the item should only be saved if its current version matches the expected one.
926+
Set to `False` for a 'last-write-wins' strategy.
911927
"""
912928
attribute_values = self.serialize(null_check)
913929
hash_key_attribute = self._hash_key_attribute()
@@ -921,7 +937,7 @@ def _get_save_args(self, null_check: bool = True, condition: Optional[Condition]
921937
if range_key is not None:
922938
kwargs['range_key'] = range_key
923939
version_condition = self._handle_version_attribute(attributes=attribute_values)
924-
if version_condition is not None:
940+
if add_version_condition and version_condition is not None:
925941
condition &= version_condition
926942
kwargs['attributes'] = attribute_values
927943
kwargs['condition'] = condition

pynamodb/transactions.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,11 @@ def condition_check(self, model_cls: Type[_M], hash_key: _KeyType, range_key: Op
100100
)
101101
self._condition_check_items.append(operation_kwargs)
102102

103-
def delete(self, model: _M, condition: Optional[Condition] = None) -> None:
104-
operation_kwargs = model.get_delete_kwargs_from_instance(condition=condition)
103+
def delete(self, model: _M, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> None:
104+
operation_kwargs = model.get_delete_kwargs_from_instance(
105+
condition=condition,
106+
add_version_condition=add_version_condition,
107+
)
105108
self._delete_items.append(operation_kwargs)
106109

107110
def save(self, model: _M, condition: Optional[Condition] = None, return_values: Optional[str] = None) -> None:
@@ -112,11 +115,15 @@ def save(self, model: _M, condition: Optional[Condition] = None, return_values:
112115
self._put_items.append(operation_kwargs)
113116
self._models_for_version_attribute_update.append(model)
114117

115-
def update(self, model: _M, actions: List[Action], condition: Optional[Condition] = None, return_values: Optional[str] = None) -> None:
118+
def update(self, model: _M, actions: List[Action], condition: Optional[Condition] = None,
119+
return_values: Optional[str] = None,
120+
*,
121+
add_version_condition: bool = True) -> None:
116122
operation_kwargs = model.get_update_kwargs_from_instance(
117123
actions=actions,
118124
condition=condition,
119-
return_values_on_condition_failure=return_values
125+
return_values_on_condition_failure=return_values,
126+
add_version_condition=add_version_condition,
120127
)
121128
self._update_items.append(operation_kwargs)
122129
self._models_for_version_attribute_update.append(model)

tests/integration/test_transaction_integration.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,11 @@ def test_transaction_write_with_version_attribute(connection):
327327
foo3 = Foo(3)
328328
foo3.save()
329329

330+
foo42 = Foo(42)
331+
foo42.save()
332+
foo42_dup = Foo.get(42)
333+
foo42_dup.save() # increment version w/o letting foo4 "know"
334+
330335
with TransactWrite(connection=connection) as transaction:
331336
transaction.condition_check(Foo, 1, condition=(Foo.bar.exists()))
332337
transaction.delete(foo2)
@@ -337,13 +342,23 @@ def test_transaction_write_with_version_attribute(connection):
337342
Foo.star.set('birdistheword'),
338343
]
339344
)
345+
transaction.update(
346+
foo42,
347+
actions=[
348+
Foo.star.set('last write wins'),
349+
],
350+
add_version_condition=False,
351+
)
340352

341353
assert Foo.get(1).version == 1
342354
with pytest.raises(DoesNotExist):
343355
Foo.get(2)
344356
# Local object's version attribute is updated automatically.
345357
assert foo3.version == 2
346358
assert Foo.get(4).version == 1
359+
foo42 = Foo.get(42)
360+
assert foo42.version == foo42_dup.version + 1 == 3 # ensure version is incremented
361+
assert foo42.star == 'last write wins' # ensure last write wins
347362

348363

349364
@pytest.mark.ddblocal
@@ -372,8 +387,10 @@ def test_transaction_write_with_version_attribute_condition_failure(connection):
372387
with pytest.raises(TransactWriteError) as exc_info:
373388
with TransactWrite(connection=connection) as transaction:
374389
transaction.save(Foo(21))
375-
assert get_error_code(exc_info.value) == TRANSACTION_CANCELLED
376-
assert 'ConditionalCheckFailed' in get_error_message(exc_info.value)
390+
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
391+
assert len(exc_info.value.cancellation_reasons) == 1
392+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
393+
assert isinstance(exc_info.value.cause, botocore.exceptions.ClientError)
377394
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
378395

379396
with pytest.raises(TransactWriteError) as exc_info:
@@ -384,15 +401,17 @@ def test_transaction_write_with_version_attribute_condition_failure(connection):
384401
Foo.star.set('birdistheword'),
385402
]
386403
)
387-
assert get_error_code(exc_info.value) == TRANSACTION_CANCELLED
388-
assert 'ConditionalCheckFailed' in get_error_message(exc_info.value)
404+
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
405+
assert len(exc_info.value.cancellation_reasons) == 1
406+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
389407
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
390408
# Version attribute is not updated on failure.
391409
assert foo2.version is None
392410

393411
with pytest.raises(TransactWriteError) as exc_info:
394412
with TransactWrite(connection=connection) as transaction:
395413
transaction.delete(foo2)
396-
assert get_error_code(exc_info.value) == TRANSACTION_CANCELLED
397-
assert 'ConditionalCheckFailed' in get_error_message(exc_info.value)
414+
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
415+
assert len(exc_info.value.cancellation_reasons) == 1
416+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
398417
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE

0 commit comments

Comments
 (0)