Optimistic Locking

Optimistic locking is useful in situations where you might ordinarily use aSELECT FOR UPDATE (or in SQLite, BEGIN IMMEDIATE). For example, you mightfetch a user record from the database, make some modifications, then save themodified user record. Typically this scenario would require us to lock the userrecord for the duration of the transaction, from the moment we select it, tothe moment we save our changes.

In optimistic locking, on the other hand, we do not acquire any lock andinstead rely on an internal version column in the row we’re modifying. Atread time, we see what version the row is currently at, and on save, we ensurethat the update takes place only if the version is the same as the one weinitially read. If the version is higher, then some other process must havesnuck in and changed the row – to save our modified version could result inthe loss of important changes.

It’s quite simple to implement optimistic locking in Peewee, here is a baseclass that you can use as a starting point:

  1. from peewee import *
  2.  
  3. class ConflictDetectedException(Exception): pass
  4.  
  5. class BaseVersionedModel(Model):
  6. version = IntegerField(default=1, index=True)
  7.  
  8. def save_optimistic(self):
  9. if not self.id:
  10. # This is a new record, so the default logic is to perform an
  11. # INSERT. Ideally your model would also have a unique
  12. # constraint that made it impossible for two INSERTs to happen
  13. # at the same time.
  14. return self.save()
  15.  
  16. # Update any data that has changed and bump the version counter.
  17. field_data = dict(self.__data__)
  18. current_version = field_data.pop('version', 1)
  19. self._populate_unsaved_relations(field_data)
  20. field_data = self._prune_fields(field_data, self.dirty_fields)
  21. if not field_data:
  22. raise ValueError('No changes have been made.')
  23.  
  24. ModelClass = type(self)
  25. field_data['version'] = ModelClass.version + 1 # Atomic increment.
  26.  
  27. query = ModelClass.update(**field_data).where(
  28. (ModelClass.version == current_version) &
  29. (ModelClass.id == self.id))
  30. if query.execute() == 0:
  31. # No rows were updated, indicating another process has saved
  32. # a new version. How you handle this situation is up to you,
  33. # but for simplicity I'm just raising an exception.
  34. raise ConflictDetectedException()
  35. else:
  36. # Increment local version to match what is now in the db.
  37. self.version += 1
  38. return True

Here’s an example of how this works. Let’s assume we have the following modeldefinition. Note that there’s a unique constraint on the username – this isimportant as it provides a way to prevent double-inserts.

  1. class User(BaseVersionedModel):
  2. username = CharField(unique=True)
  3. favorite_animal = CharField()

Example:

  1. >>> u = User(username='charlie', favorite_animal='cat')
  2. >>> u.save_optimistic()
  3. True
  4.  
  5. >>> u.version
  6. 1
  7.  
  8. >>> u.save_optimistic()
  9. Traceback (most recent call last):
  10. File "<stdin>", line 1, in <module>
  11. File "x.py", line 18, in save_optimistic
  12. raise ValueError('No changes have been made.')
  13. ValueError: No changes have been made.
  14.  
  15. >>> u.favorite_animal = 'kitten'
  16. >>> u.save_optimistic()
  17. True
  18.  
  19. # Simulate a separate thread coming in and updating the model.
  20. >>> u2 = User.get(User.username == 'charlie')
  21. >>> u2.favorite_animal = 'macaw'
  22. >>> u2.save_optimistic()
  23. True
  24.  
  25. # Now, attempt to change and re-save the original instance:
  26. >>> u.favorite_animal = 'little parrot'
  27. >>> u.save_optimistic()
  28. Traceback (most recent call last):
  29. File "<stdin>", line 1, in <module>
  30. File "x.py", line 30, in save_optimistic
  31. raise ConflictDetectedException()
  32. ConflictDetectedException: current version is out of sync