Testing Optimistic Locking Handling with Spring Boot and JPA
While optimistic locking handling in JPA is relatively well-known, it is usually poorly tested or not tested at all. In this blog post I will show you firstly what does optimistic locking handling mean and how you could implement it within a Spring Boot Application and JPA. After that you could see a way to write integration tests, which I hope could surprise you with their simplicity and efficiency!
But before that, let's take a closer look at what optimistic locking is all about.
Optimistic Locking Explained
If you have a row in a relational database table, which could be updated by concurrent transactions or by concurrent long conversations, then most probably you should have optimistic locking in place.
In fact, without any locking mechanism in your live system, most probably a silent data loss is happening in your data base even right now, at the moment of reading 😱 !
I will demonstrate to you a couple of problems related to concurrency, which may be solved by optimistic locking.
Case1: Concurrent Database Transactions Problem
In most RDBMS the default transaction isolation level is at least read committed. It solves dirty reads phenomena, which guarantees that the transaction reads only committed data by other transactions.
Because of the isolation level and of the concurrent nature of the transactions, the conflict could happen when two transactions have been processed simultaneously:
- Both of them are reading the same state of the record;
- Both of them are making different changes;
- One of them will commit earlier, persisting its changes to database;
- The second one will commit a bit later, silently overwriting the data persisted by the previous one.
Case2: Concurrent Long Conversations Problem
A conflict could happen even outside of the transaction's scope. In the case of long conversations there are multiple transactions in place, but a conflict on a shared resource could have similar silent data loss consequences, as in the example before.
Example scenario:
- Two users are editing the same record through a GUI form, having a [Save] button;
- Both of them have as much time at their disposal as the max time duration of the user session;
- Initially they have the same state of the record. They both are working outside of the database transactions (in opposite case the system would have very poor performance);
- One of the users clicks the [Save] button earlier. It will persist the user's changes to database;
- Later, the second user clicks the [Save] button. It will persist the user's changes to the database, silently overwriting the data persisted by the previous user.
The Elegant Optimistic Locking Solution
To protect the entities against the explained concurrency problems, a new attribute version has been added. There are different implementations for the type of this attribute, but the most robust one is just a numeric counter (in Java it could be Long).
Case1: Concurrent Database Transactions Solution
Case2: Concurrent Long Conversations Solution
- When two threads (case1) or two users (case2) are retrieving the same record, they have initially the same value of its version attribute;
- After the change, when a database transaction tries to commit the record, it will increase its version with 1, while controlling with the same query that the version attribute in database has still the expected value (bellow an example of the query). Doing so, the first concurrent transaction will succeed, while the second will throw an optimistic locking exception.
🔔 Even without any additional exception handling, the situation has already improved: No more silent data loss would happen in case of race conditions!
SQL Query Example For Updating Record With Optimistic Locking
update
item
set
version=1,
amount=10
where
id='abcd1234'
and
version=0
- Adding check through logical AND condition on the attribute version is the only performance overhead, which you get using optimistic locking;
- This operation alone will not cause any bottleneck in case of multi-threading;
- It is much faster solution compared to exclusive pessimistic locking, where 1 transaction obtains the lock and all others are blocked, waiting for the release of the lock.
🔔 That's why optimistic locking is the preferred solution over pessimistic locking in cases where the problem can be solved using either methods!
Now you have to decide what do you want to do with this optimistic locking exception.
The Possible Optimistic Locking Exception Handling
Depends on the context, there are different ways to handle optimistic locking exception:
- In Case2: It could be just forwarded back to the unfortunate user as friendly (or unfriendly? 😉) popup message, notifying that another user has already changed the record and that the user has to reinsert the data again;
- In Case1: It could be an auto-retry mechanism in place, which reloads the latest state of the record, do some merge (if possible) and then retries to commit the data;
- Or it could be some combined mechanism: It could try automatically to merge and to commit again. In case of conflicts, it could delegate the handling of the problem to the user.
Not One-Size-Fits-All solution
There are a variety of concurrency problems, which could not be solved with optimistic locking. For example:
- High level of concurrency on the same record, which could be handy to handle with a huge amount of retries;
- Has to be guaranteed that certain operation happens exactly one time;
- Concurrency problems, where some form of join-synchronisation between states of concurrent records is required;
In that case there are alternative handling strategies. Some of them are:
- Race condition avoidance due to accordingly adapted Software Architecture;
- Pessimistic locking;
- A software component (for example a microservice) with error handling responsibility, which could involve both automation or/and human interaction.
Optimistic Locking Handling Implemented
Project setup
If you wish you could clone the complete example from GitHub.
Or you could start from scratch a new Spring Boot Project with Spring Initializr selecting the following modules:
- Maven Project
- Spring Boot 2.3.0 (works with Spring Boot 2.2.0+ as well, which is the first Sprint Boot version to make default use of JUnit 5)
- Add dependencies: Spring Data JPA, H2 Database, Lombok
Implement Your Entities
Base Entity
@Setter
@Getter
@MappedSuperclass
public class BaseEntity {
@Version
private Long version;
}
- Adding @Version attribute is all what you need to activate the optimistic locking for an entity;
- In case most of your entities require optimistic locking mechanism, then it could be a good idea to create a BaseEntity class;
- The attribute @MappedSuperclass declares for JPA that this is not an entity, but it could be extended by other entities.
Item
@Data
@EqualsAndHashCode(callSuper = false)
@Entity
public class Item extends BaseEntity {
@Id
private String id = UUID.randomUUID().toString();
private int amount = 0;
}
- Let's have for the sake of this example a simple entity called Item. It could be an abstraction for apples, postmarks etc. As long as it make sense to have the amount attribute in it 😉;
- id attribute is generated by Java as UUID;
- To guarantee the consistency of equals according to the java specification for equals, it could be a good idea to exclude the generation of parent's equals and hashcodes.
Implement your Repository and Services
ItemRepository
public interface ItemRepository extends CrudRepository<Item, String> {
}
This is a typical CrudRepository which will have out-of-the-box implementation, provided by Spring Data for the typical CRUD operations.
ItemService
@RequiredArgsConstructor
@Service
public class ItemService {
private final ItemRepository itemRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void incrementAmount(String id, int amount) {
Item item = itemRepository.findById(id).orElseThrow(EntityNotFoundException::new);
item.setAmount(item.getAmount() + amount);
}
}
- ItemService is a low-level Service, controlled by InventoryService;
- The Lombok annotation RequiredArgsConstructor creates a constructor for all final attributes (in this case ItemRepository);
- Having itemRepository as parameter in a constructor will provide autowiring by Spring Framework;
- It marks its low-level transaction as REQUIRES_NEW, because it's a nested transaction, required for correct optimistic locking handling by its parent Service.
- findById(id) returns an Optional of Item. Instead of writing long code with controlling if isPresent() then get() else throw exception, you could just use the orElseThrow() method to do the same thing.
InventoryService
@Slf4j
@RequiredArgsConstructor
@Service
public class InventoryService {
private final ItemService itemService;
@Transactional(readOnly = true)
public void incrementProductAmount(String itemId, int amount) {
try {
itemService.incrementAmount(itemId, amount);
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("Somebody has already updated the amount for item:{} in concurrent transaction. Will try again...", itemId);
itemService.incrementAmount(itemId, amount);
}
}
}
- InventoryService is a high-level Service, controlling ItemService;
- The itemService will be autowired by Spring Framework through the constructor (as explained in the ItemService);
- In case of optimistic locking exception, it will try to call incrementAmount method again. Because it passes item ID, instead of the whole entity, incrementAmount method will load the latest version of the entity from the database;
- InventoryService marks the transaction as readOnly=true for better performance, because its high-level transaction does not need to do any changes of the managed entities. The read only transactions disable the automatic dirty checking mechanism in JPA/Hibernate. The newest versions of Spring Data could offer as well additional performance optimisations.
🔔 The usage of read only transaction could not be the case in your implementation. So it has nothing to do with the optimistic locking handling. But it's nice to know, that if you wish, you could mark an external transaction as ReadOnly!
Optimistic Locking Handling Tested
InventoryServiceTest
@SpringBootTest
class InventoryServiceTest {
@Autowired
private InventoryService inventoryService;
@Autowired
private ItemRepository itemRepository;
@SpyBean
private ItemService itemService;
private final List<Integer> itemAmounts = Arrays.asList(10, 5);
@Test
void shouldIncrementItemAmount_withoutConcurrency() {
// given
final Item srcItem = itemRepository.save(new Item());
assertEquals(0, srcItem.getVersion());
// when
for(final int amount: itemAmounts) {
inventoryService.incrementProductAmount(srcItem.getId(), amount);
}
// then
final Item item = itemRepository.findById(srcItem.getId()).get();
assertAll(
() -> assertEquals(2, item.getVersion()),
() -> assertEquals(15, item.getAmount()),
() -> verify(itemService, times(2)).incrementAmount(anyString(), anyInt())
);
}
@Test
void shouldIncrementItemAmount_withOptimisticLockingHandling() throws InterruptedException {
// given
final Item srcItem = itemRepository.save(new Item());
assertEquals(0, srcItem.getVersion());
// when
final ExecutorService executor = Executors.newFixedThreadPool(itemAmounts.size());
for (final int amount : itemAmounts) {
executor.execute(() -> inventoryService.incrementProductAmount(srcItem.getId(), amount));
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
// then
final Item item = itemRepository.findById(srcItem.getId()).get();
assertAll(
() -> assertEquals(2, item.getVersion()),
() -> assertEquals(15, item.getAmount()),
() -> verify(itemService, times(3)).incrementAmount(anyString(), anyInt())
);
}
}
This integration test has two test methods:
- One for testing the invocation of inventoryService sequentially, avoiding any concurrency;
- One for testing the invocation of inventoryService simultaneously (in parallel threads), provoking in such a way an optimistic locking exception.
You could notice that both of the methods are very similar and have only few differences between them.
🔔 The usage of JUnit 5 assertAll operator is able to show parallel test assertion failures. Actually if you don't use this operator, you could just write the very same test with JUnit 4 primitives!
Do The Optimistic Locking Handling Testing Right!
In order to test optimistic locking handling correctly you have to satisfy the following needs:
- You need to have multi-threading in place;
- Your threads have to start exactly at the same time:
- executor.execute() method has asynchronous execution, which leaves the execution of the thread in background and goes back to the current thread execution;
- executor.awaitTermination() is blocking mechanism, which will wait till the end of the threads, but no more than 1 minute;
- You have to be sure that your threads are managing separate database transactions. This is guaranteed by Spring Framework because it stores the transaction's state in LocalThread and every thread has a separate instance of it;
- After the multiple-thread-join point you should reload the record from database in order to have its latest state;
- In order to be sure that optimistic locking has actually taken place you need to assert it somehow. An elegant way to do this is with the help of Mockito SpyBean. These beans are wrapping the actual bean without mocking their methods. They have an internal counter which tracks the amount of method's invocations.
Why, in case of Optimistic Locking Handling, the method incrementAmout is called 3 times?
In our scenario it means that:
- The faster transaction hasn't encountered any optimistic locking exception. It has been committed straight into database, successfully incrementing the attribute version of the persisted record. At this moment the invocation's counter of incrementAmount method is 1;
- The second transaction will cause an optimistic locking exception. The invocation's counter of incrementAmount will go first to 2. Then the InventoryService will handle the optimistic locking exception and will try again, increasing the counter to 3.
Now you could notice that:
- In the method without optimistic locking, the invocation's counter of the method incrementAmount is expected to be 2;
- In the method with optimistic locking, the counter is expected to be 3.
Summary
- Optimistic locking is a powerful concurrency mechanism. When correctly used, it can solve different concurrency problems without causing performance issues;
- It fits naturally without overhead. You just need to add an attribute version with the annotation @Version in all JPA entities, which need optimistic locking. Doing so, thanks to JPA, you will automatically solve the tricky silent data loss problem;
- Optimistic locking is not a one-size-fits-all solution. There are as well other better ways to solve some particular concurrency problems;
- Testing your optimistic locking handling will be always a good investment for the time spent. Furthermore, it could help you to prevent future bugs, caused by difficult to reproduce race conditions;
- And last, but not least: doing optimistic locking handling testing certainly will improve your's and your customer's optimistic mood! 😃
🔔 This example is available at GitHub.