Skip to content

Commit 0cf2e94

Browse files
authored
Add 'add_version_condition' arg (pynamodb#1172)
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 e37635e commit 0cf2e94

File tree

7 files changed

+357
-251
lines changed

7 files changed

+357
-251
lines changed

docs/optimistic_locking.rst

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

docs/release_notes.rst

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@ This is a major release and contains breaking changes. Please read the notes bel
1111
* The attributes' internal encoding has changed. To prevent this change going unnoticed, :code:`legacy_encoding` have been made required: see :doc:`upgrading_binary` for details.
1212
If your codebase uses :py:class:`~pynamodb.attributes.BinaryAttribute` or :py:class:`~pynamodb.attributes.BinarySetAttribute`,
1313
go over the attribute declarations and mark them accordingly.
14-
* When using binary attributes, the return value of :py:func:`~pynamodb.models.Model.serialize` will no longer be JSON-serializable
15-
since it will contain :code:`bytes` objects. Use `:py:func:`~pynamodb.models.Model.to_dynamodb_dict`
14+
* When using binary attributes, the return value of :meth:`~pynamodb.models.Model.serialize` will no longer be JSON-serializable
15+
since it will contain :code:`bytes` objects. Use :meth:`~pynamodb.models.Model.to_dynamodb_dict`
1616
for a safe JSON-serializable representation.
1717

1818
* Python 3.6 is no longer supported.
19-
* Index count, query, and scan methods are now instance methods.
19+
* :meth:`Index.count <pynamodb.indexes.Index.count>`, :meth:`Index.query <pynamodb.indexes.Index.query>`,
20+
and :meth:`Indexn.scan <pynamodb.indexes.Index.scan>` are now instance methods.
21+
22+
Other changes in this release:
23+
24+
* :meth:`~pynamodb.models.Model.save`, :meth:`~pynamodb.models.Model.update`, :meth:`~pynamodb.models.Model.delete_item`,
25+
and :meth:`~pynamodb.models.Model.delete` now accept a ``add_version_condition`` parameter.
26+
See :ref:`optimistic_locking_version_condition` for more details.
2027

2128

2229
v5.3.2

pynamodb/attributes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ def deserialize(self, value):
772772

773773
class VersionAttribute(NumberAttribute):
774774
"""
775-
A version attribute
775+
A number attribute that implements :ref:`optimistic locking <optimistic_locking>`.
776776
"""
777777
null = True
778778

pynamodb/models.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -395,26 +395,35 @@ def batch_write(cls: Type[_T], auto_commit: bool = True, settings: OperationSett
395395
"""
396396
return BatchWrite(cls, auto_commit=auto_commit, settings=settings)
397397

398-
def delete(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Any:
398+
def delete(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default,
399+
*, add_version_condition: bool = True) -> Any:
399400
"""
400-
Deletes this object from dynamodb
401+
Deletes this object from DynamoDB.
401402
403+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
404+
specifies whether the item should only be deleted if its current version matches the expected one.
405+
Set to `False` for a 'delete anyway' strategy.
402406
:raises pynamodb.exceptions.DeleteError: If the record can not be deleted
403407
"""
404408
hk_value, rk_value = self._get_hash_range_key_serialized_values()
409+
405410
version_condition = self._handle_version_attribute()
406-
if version_condition is not None:
411+
if add_version_condition and version_condition is not None:
407412
condition &= version_condition
408413

409414
return self._get_connection().delete_item(hk_value, range_key=rk_value, condition=condition, settings=settings)
410415

411-
def update(self, actions: List[Action], condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Any:
416+
def update(self, actions: List[Action], condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default, *, add_version_condition: bool = True) -> Any:
412417
"""
413418
Updates an item using the UpdateItem operation.
414419
415420
:param actions: a list of Action updates to apply
416421
:param condition: an optional Condition on which to update
417422
:param settings: per-operation settings
423+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
424+
specifies whether only to update if the version matches the model that is currently loaded.
425+
Set to `False` for a 'last write wins' strategy.
426+
Regardless, the version will always be incremented to prevent "rollbacks" by concurrent :meth:`save` calls.
418427
:raises ModelInstance.DoesNotExist: if the object to be updated does not exist
419428
:raises pynamodb.exceptions.UpdateError: if the `condition` is not met
420429
"""
@@ -423,7 +432,7 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, s
423432

424433
hk_value, rk_value = self._get_hash_range_key_serialized_values()
425434
version_condition = self._handle_version_attribute(actions=actions)
426-
if version_condition is not None:
435+
if add_version_condition and version_condition is not None:
427436
condition &= version_condition
428437

429438
data = self._get_connection().update_item(hk_value, range_key=rk_value, return_values=ALL_NEW, condition=condition, actions=actions, settings=settings)
@@ -434,11 +443,11 @@ def update(self, actions: List[Action], condition: Optional[Condition] = None, s
434443
self.deserialize(item_data)
435444
return data
436445

437-
def save(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default) -> Dict[str, Any]:
446+
def save(self, condition: Optional[Condition] = None, settings: OperationSettings = OperationSettings.default, *, add_version_condition: bool = True) -> Dict[str, Any]:
438447
"""
439448
Save this object to dynamodb
440449
"""
441-
args, kwargs = self._get_save_args(condition=condition)
450+
args, kwargs = self._get_save_args(condition=condition, add_version_condition=add_version_condition)
442451
kwargs['settings'] = settings
443452
data = self._get_connection().put_item(*args, **kwargs)
444453
self.update_local_version_attribute()
@@ -467,11 +476,13 @@ def get_update_kwargs_from_instance(
467476
actions: List[Action],
468477
condition: Optional[Condition] = None,
469478
return_values_on_condition_failure: Optional[str] = None,
479+
*,
480+
add_version_condition: bool = True,
470481
) -> Dict[str, Any]:
471482
hk_value, rk_value = self._get_hash_range_key_serialized_values()
472483

473484
version_condition = self._handle_version_attribute(actions=actions)
474-
if version_condition is not None:
485+
if add_version_condition and version_condition is not None:
475486
condition &= version_condition
476487

477488
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)
@@ -480,11 +491,13 @@ def get_delete_kwargs_from_instance(
480491
self,
481492
condition: Optional[Condition] = None,
482493
return_values_on_condition_failure: Optional[str] = None,
494+
*,
495+
add_version_condition: bool = True,
483496
) -> Dict[str, Any]:
484497
hk_value, rk_value = self._get_hash_range_key_serialized_values()
485498

486499
version_condition = self._handle_version_attribute()
487-
if version_condition is not None:
500+
if add_version_condition and version_condition is not None:
488501
condition &= version_condition
489502

490503
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)
@@ -886,13 +899,16 @@ def _get_schema(cls) -> ModelSchema:
886899

887900
return schema
888901

889-
def _get_save_args(self, condition: Optional[Condition] = None) -> Tuple[Iterable[Any], Dict[str, Any]]:
902+
def _get_save_args(self, condition: Optional[Condition] = None, *, add_version_condition: bool = True) -> Tuple[Iterable[Any], Dict[str, Any]]:
890903
"""
891904
Gets the proper *args, **kwargs for saving and retrieving this object
892905
893906
This is used for serializing items to be saved.
894907
895908
:param condition: If set, a condition
909+
:param add_version_condition: For models which have a :class:`~pynamodb.attributes.VersionAttribute`,
910+
specifies whether the item should only be saved if its current version matches the expected one.
911+
Set to `False` for a 'last-write-wins' strategy.
896912
"""
897913
attribute_values = self.serialize(null_check=True)
898914
hash_key_attribute = self._hash_key_attribute()
@@ -906,7 +922,7 @@ def _get_save_args(self, condition: Optional[Condition] = None) -> Tuple[Iterabl
906922
if range_key is not None:
907923
kwargs['range_key'] = range_key
908924
version_condition = self._handle_version_attribute(attributes=attribute_values)
909-
if version_condition is not None:
925+
if add_version_condition and version_condition is not None:
910926
condition &= version_condition
911927
kwargs['attributes'] = attribute_values
912928
kwargs['condition'] = condition

pynamodb/transactions.py

Lines changed: 11 additions & 4 deletions
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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ def test_transaction_write_with_version_attribute(connection):
323323
foo3 = Foo(3)
324324
foo3.save()
325325

326+
foo42 = Foo(42)
327+
foo42.save()
328+
foo42_dup = Foo.get(42)
329+
foo42_dup.save() # increment version w/o letting foo4 "know"
330+
326331
with TransactWrite(connection=connection) as transaction:
327332
transaction.condition_check(Foo, 1, condition=(Foo.bar.exists()))
328333
transaction.delete(foo2)
@@ -333,13 +338,23 @@ def test_transaction_write_with_version_attribute(connection):
333338
Foo.star.set('birdistheword'),
334339
]
335340
)
341+
transaction.update(
342+
foo42,
343+
actions=[
344+
Foo.star.set('last write wins'),
345+
],
346+
add_version_condition=False,
347+
)
336348

337349
assert Foo.get(1).version == 1
338350
with pytest.raises(DoesNotExist):
339351
Foo.get(2)
340352
# Local object's version attribute is updated automatically.
341353
assert foo3.version == 2
342354
assert Foo.get(4).version == 1
355+
foo42 = Foo.get(42)
356+
assert foo42.version == foo42_dup.version + 1 == 3 # ensure version is incremented
357+
assert foo42.star == 'last write wins' # ensure last write wins
343358

344359

345360
@pytest.mark.ddblocal
@@ -369,7 +384,8 @@ def test_transaction_write_with_version_attribute_condition_failure(connection):
369384
with TransactWrite(connection=connection) as transaction:
370385
transaction.save(Foo(21))
371386
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
372-
assert 'ConditionalCheckFailed' in exc_info.value.cause_response_message
387+
assert len(exc_info.value.cancellation_reasons) == 1
388+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
373389
assert isinstance(exc_info.value.cause, botocore.exceptions.ClientError)
374390
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
375391

@@ -382,7 +398,8 @@ def test_transaction_write_with_version_attribute_condition_failure(connection):
382398
]
383399
)
384400
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
385-
assert 'ConditionalCheckFailed' in exc_info.value.cause_response_message
401+
assert len(exc_info.value.cancellation_reasons) == 1
402+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
386403
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE
387404
# Version attribute is not updated on failure.
388405
assert foo2.version is None
@@ -391,5 +408,6 @@ def test_transaction_write_with_version_attribute_condition_failure(connection):
391408
with TransactWrite(connection=connection) as transaction:
392409
transaction.delete(foo2)
393410
assert exc_info.value.cause_response_code == TRANSACTION_CANCELLED
394-
assert 'ConditionalCheckFailed' in exc_info.value.cause_response_message
411+
assert len(exc_info.value.cancellation_reasons) == 1
412+
assert exc_info.value.cancellation_reasons[0].code == 'ConditionalCheckFailed'
395413
assert Foo.Meta.table_name in exc_info.value.cause.MSG_TEMPLATE

0 commit comments

Comments
 (0)