Hibernate Optimistic locking with Spring Boot

showing two cases: 1. concurrent database transactions & 2. concurrent long (UI) conversations

Harald Vogl's photo
Harald Vogl
·Jun 21, 2022·

6 min read

Optimistic locking is a concept to avoid concurrent changes on the same data. To be more precise: It's not about preventing concurrent changes (this would be pessimistic locking), but to allow and detect those changes happening at the same time and avoid concurrent modification.

There are several articles out dealing with this topic. Some examples include: Optimistic Locking in JPA [1], Optimistic locking with JPA and Hibernate [2], Testing Optimistic Locking Handling with Spring Boot and JPA [3].

Usually you find articles only handling the problem of concurrent database transactions: Two or more transactions load the same state of an entity, then modify and save it to the database again, leading to the fact that the last transaction which is committed overwrites the changes from the previous ones.

But there is a second problem, very well described in article [3]: concurrent long conversations: Typically two or more users are loading the same state of an entity to their local UI, then modify some fields and send their state back to the server. Without optimistic locking, the user who saves last, overwrites changes from the previous users without knowing that the data was changed by others in the meantime.

Normally the proposed solution is to just add an @Version attribute to your entity. But typically this is not working for the concurrent long conversations problem. To understand why, let's first take a look at an example using the @Version attribute as proposed.

The code is written in kotlin and available on GitHub [4].

EntityMapping

To make an entity ready for optimistic locking, you need to add a version attribute to your database table and entity mapping like this:

@Entity 
class Person( 
    @Id @Type(type = "uuid-char") var id: UUID, 
    var name: String, 
    var address: String 
) { 
    @Version 
    var version: Long? = null 
}

TIP: If you have an application generated key, e.g., like the UUID or any other business key from the example above, I recommend using a nullable type for the version field (Wrapper type Long instead of primitive long in java). This saves an extra query on insert or updates, because Spring Data is then using the version field to determine if it is a new entity to be saved (version is null), hence an insert is necessary or if it is an existing one to update. If neither the ID nor the version is a nullable type (and the org.springframework.data.domain.Persistable interface is also not implemented in the entity), then there is an extra select query before the insert or update is done to determine if insert or update is necessary.

By adding this @Version field, the update and also delete queries are extended by the and version = ? in the where clause instead of just using the id:

update person set address=?, name=?, version=? where id=? and version=?

Keep in mind that changing the version of an entity programmatically during a transaction is not possible. You can set the version programmatically without any exception being thrown, but the manual set version will be ignored (except when having a detached entity and then attaching this entity to the persistence context).

Testing the functionality is only possible by having multiple threads calling the same transaction at the same time. There is an unit test example in the GitHub project, similar to the one from article [3]:

@Test
fun `concurrent database transactions`(output: CapturedOutput) {
    // given
    val createDto = PersonDto(UUID.fromString("a426dbe8-e711-45cc-a2f7-5651dc2ea124"), "John Doe", "Doe Street 1")
    val createdDto = personApi.createPerson(createDto)
    // when
    val executor = Executors.newFixedThreadPool(5)
    for (i in 1..5) {
        val updateDto = PersonDto(createdDto.id, "${createdDto.name} update $i", "${createdDto.address} update $i", 0)
        executor.execute {
            try {
                personApi.updatePerson(updateDto)
            } catch (_: Exception) {
                // ignore optimistic lock exceptions
            }
        }
    }
    executor.shutdown()
    executor.awaitTermination(10, TimeUnit.SECONDS)
    // then
    assertThat(personApi.getPerson(createDto.id).version).isEqualTo(1)
    verify(exactly = 5) { personService.updatePerson(any()) }
    assertThat(output.all).contains("ObjectOptimisticLockingFailureException: " +
            "Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; " +
            "statement executed: update person set address=?, name=?, version=? where id=? and version=?")
}

Handling concurrent long conversations

Preventing accidental overwriting of changes from user B, which were saved while the input form was being changed and then saved by the user A, is not supported by Hibernate out of the box.

The solution is to manually check the version in your service code and throw an exception if a concurrent modification is detected:

override fun updatePerson(personDto: PersonDto): PersonDto {
    val person = getOrThrow(personDto.id).also {
        if (it.version != personDto.version) {
            throw OptimisticLockException(it)
        }
        it.name = personDto.name
        it.address = personDto.address
    }
    return saveAndMap(person)
}
private fun getOrThrow(id: UUID) = personRepository.findByIdOrNull(id) ?: throw EntityNotFoundException()

private fun map(person: Person) = PersonDto(person.id, person.name, person.address, person.version)

// flush is necessary to get new version for dto mapping
private fun saveAndMap(person: Person) = map(personRepository.saveAndFlush(person))

Writing a unit test for this scenario is quite easy – just try to perform subsequent changes with the same version of the entity:

@Test
fun `concurrent long conversations`() {
    // given
    val createDto =
        PersonDto(UUID.fromString("a426dbe8-e711-45cc-a2f7-5651dc2ea124"), "John Doe", "Doe Street 1")
    val createdDto = personApi.createPerson(createDto)
    // when
    val updateDto =
        PersonDto(createdDto.id, "${createdDto.name} update", "${createdDto.address} update", 0)
    personApi.updatePerson(updateDto)
    // then
    assertThatThrownBy { personApi.updatePerson(updateDto) } // version is 1 in the meantime!
        .hasMessageContaining("${HttpStatus.UNPROCESSABLE_ENTITY.value()}")
}

The API controller converts the OptimistickLockException to HTTP Status 422 UNPROCESSABLE_ENTITY which is caught here in the unit test

Instead of manually checking the version in the service code, you could also implement a custom hibernate FlushEntityEventListener to compare the manual set version on the entity with the version from the database before flushing. In both cases some manual work is necessary, and I prefer the service solution because it is less hacky and more explicit.

Alternative solution without @Version

Instead of having the @Version field, you could use @DynamicUpdate with the @OptimisticLock annotation:

@Entity 
@DynamicUpdate 
@OptimisticLocking(type = OptimisticLockType.ALL) 
class Person ( 
    @Id @Type(type = "uuid-char") var id: UUID, 
    var name: String, 
    var address: String 
)

This would lead to an update & delete query using all the fields in the where clause:

update person set address=?, name=?, version=? where id=? and name=? and address=?

A detailed description can be found in the article How to prevent OptimisticLockException with Hibernate versionless optimistic locking [5].

However, solving the problem of concurrent long conversations by using @DynamicUpdate can only be achieved, if the UI sends all the previous field values back to the server, which are then programmatically compared to all the old field values, instead of comparing the single version field in the service code as shown above.

Conclusion

Implementing optimistic locking with hibernate and Spring Data JPA is quite easy. Adding an @Version field to your entities does most of the job. If you need to support concurrent updates of the same entity version, like several users using the same data on an UI at the same time, you have to do a simple if check before doing any changes to your data.

The sample code with a running REST interface for manual testing and unit tests can be found on GitHub[4].

Enjoy catching and handling optimistic lock exceptions! :)

 
Share this