Testing Pessimistic Locking Handling with Spring Boot and JPA
Testing pessimistic locking handling in JPA is tricky because of the lack of JPA support of LockTimeout by different RDBMS providers. Yet leaving a critical part of the code untested is against the software craftsmanship's principles. In this blog post, I will show you through the medium of 3 GitHub projects how you could test it within Spring Boot Application against Oracle, MySQL and PostgreSQL.
But before that, let's try to identify the user needs for a viable solution:
User Needs For Testing Pessimistic Locking Handling
- It should be possible to integrate the solution in most CI pipelines, based on an in-memory database;
- It should be possible to test it periodically against the production database, due to the different pessimistic locking specification & implementation between it and the in-memory database, used for testing. Without this, you cannot be 100% sure that it works in production even if the tests work!
- It should take advantage of JPA building blocks whenever possible;
- Whenever there is a lack of JPA support, the solution should use a common codebase, which allows easy migration between RDBMS providers.
Why Testing Pessimistic Locking Handling Is Important?
Bellow the native support of pessimistic locking by different RDBMS providers (source: first-hand testing):
LockTimeout | Oracle | MySQL | PostgreSQL | H2 | Apache Derby |
---|---|---|---|---|---|
unit of measurement | sec | sec | ms | ms | sec |
nowait (or similar) | 0 | 0 | 1 | 1 | 0 |
min | 0 | 0 | 1 | 1 | 0 |
max | 2.147.483 | oo (no internal limit) | 2.147.483.647 | 2.147.483.647 | oo (no internal limit) |
infinite wait | -1 | N/A | 0 | N/A | N/A |
default | -1 | 50 | 0 | 4000 | 60 |
- Oracle and PostgreSQL, by default, never throw a LockTimeoutException (dangerous!);
- MySQL waits up to 50 seconds by default before throwing it (too long!).
Moreover, when you tune the performance of the system you would need to define custom LockTimeout duration for different queries with pessimistic locking.
π Therefore, if you don't set correct and customizable LockTimeout value for your queries, the overall performance of your system could seriously suffer!
π Even if you consider, given the complexity of pessimistic locking testing, that "manual" testing for pessimistic locking handling is enough π± - please re-consider it: In the lifecycle of the project, sooner or later, other developers will make changes to this code. If the pessimistic locking handling implementation is not covered by well-written automated tests, the probability that something will go wrong will increase with time.
Challenges In Finding A Viable Solution
Bellow the JPA support of pessimistic locking by different RDBMS providers (source: first-hand testing):
jpa feature | Oracle | MySQL | PostgreSQL | H2 | Apache Derby |
---|---|---|---|---|---|
LockModeType.PESSIMISTIC_WRITE | x | x | x | x | x |
javax.persistence.lock.timeout | x | ||||
javax.persistence.lock.timeout=0 (nowait) | x | x | |||
handling of LockTimeoutException | x | x | x | x |
- The JPA support of LockTimeout is lacking;
- The native support of LockTimeout for the observed 6 parameters (unit of measurement, nowait, min, max, infinite wait, default) differs almost completely and its handling requires different code customizations;
- Some of the most popular in-memory databases are not handling properly pessimistic locking.
Adding to that...
- The lack of complete and reliable documentation on the topic;
- The seemingly controversial user-needs for the solution ...
... well, I decided to invest some time to verify first-hand the JPA and native pessimistic locking support by different RDBMS providers and to develop a RDBMS-agnostic solution for testing pessimistic locking within Spring Boot and JPA. I am happy to offer this solution to you and I hope you can make good use of it! π
GitHub Projects With Examples How To Test Pessimistic Locking Handling
If you are eager just to grab the code, here it is:
Pessimistic Locking Explained
You could find a detailed explanation of pessimistic locking handling here.
The Solution Explained
The Most Important Implementation Choices
- The tests are running against Apache Derby in-memory database as written here. Compared to H2 in-memory database it has much better support for pessimistic locking handling and testing;
- Through commenting/uncommenting a couple of lines of code you could run the tests in the local environment against the production database;
- The codebase for testing against Oracle, MySQL, PostgreSQL and Apache Derby is optimized to have minimal possible differences. Only one class differs in the solution between the 4 providers.
- If the provider offers a full JPA support, like Oracle database, there is no native SQL code at all;
- If the JPA support is missing or limited like by MySQL, PostgreSQL and Apache Derby, minimal native SQL code is required to implement it.
UML Diagram Of The Solution
One additional interface and four extra classes are required for every Spring Data's Repository to make its containing pessimistic locking queries testable and platform-agnostic:
- ItemRepository is a standard spring data interface which extends a standard CrudRepository;
- To implement the pessimistic locking related queries manually, accessible through ItemRepository, an interface CustomizedItemRepository has been added in conformance with Spring Framework documentation:
- setLockTimeout: allows to set LockTimeout for the current query;
- getLockTimeout: allows to read the LockTimeout, set for the current query;
- getItemAndObtainPessimisticWriteLockingOnItById: implements a custom query, which will be executed with the LockTimeout set by setLockTimeout method;
- CustomizedItemRepositoryContextImpl - contains a custom implementation for the production database;
- MockCustomizedItemRepositoryContextImpl - contains a custom implementation for the in-memory Apache Derby database, used in the test environment only.
π CustomizedItemRepositoryContext is the only class which has different implementations between the 3 solutions for Oracle, PostgreSQL and MySQL.
π Spring Data does not directly allow two different implementations of CustomizedItemRepository through @Profile annotation because this interface is part of the low-level ItemRepository interface. To avoid this limitation a CustomizedItemRepositoryContext has been added, which solves the problem!
π To make it explicit that a method is obtaining a pessimistic locking instead of just naming it "getItemById" it is named "getItemAndObtainPessimisticWriteLockingOnItById". This way, the person who is using/changing this method will pay additional attention to it!
Comparison Between Different RDBMS Parameters In The Solution
parameter | Oracle | MySQL | PostgreSQL | Apache Derby |
---|---|---|---|---|
requiredToSetLockTimeoutForTestsAtStartup | false | false | false | true |
requiredToSetLockTimeoutForEveryQuery | false | true | true | false |
requiredToSetLockTimeoutQueryHint | true | false | false | false |
delayAtTheEndOfTheQueryForPessimisticLockingTestingInMs | 200 | 2000 | 0 | 0 |
minimalPossibleLockTimeOutInMs | 0 | 1000 | 1 | 0 |
lockTimeOutInMsForQueryGetItem | 5000 | 5000 | 5000 | 5000 |
Pessimistic Locking Handling Scenario Explained
- If a transaction waits longer than LockTimeout to acquire the exclusive lock, then a LockTimeoutException will be thrown without marking the transaction as rollbacked;
- If you use Spring Framework and you don't handle the exception inside the transaction then the transaction will automatically be rolled back.
- If there is some retry mechanism in place, it could try to obtain the lock again.
π Handling a LockTimeoutException through internal retry might not be the most adequate mechanism but for demonstration purposes it should be a good fit. For more information please read here!
The Handling Explained
ItemService
@RequiredArgsConstructor
@Slf4j
@Service
public class ItemService {
private final ItemRepository itemRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Item incrementAmount(UUID id, int amount) {
Item item = itemRepository.getItemAndObtainPessimisticWriteLockingOnItById(id);
item.setAmount(item.getAmount() + amount);
return item;
}
}
- ItemService executes the repository's method getItemAndObtainPessimisticWriteLockingOnItById(..) in a dedicated internal transaction (see Propagation.REQUIRES_NEW);
- This method has a timebox defined by LockTimeout parameter "lockTimeOutInMsForQueryGetItem" to obtain a pessimistic lock for the row in the table with a given id;
- If the row remains locked too long, it will throw a LockTimeoutException;
- This exception will be wrapped by the Spring Framework's exception "PessimisticLockingFailureException".
InventoryService
@RequiredArgsConstructor
@Slf4j
@Service
public class InventoryService {
private static final long PESSIMISTIC_LOCKING_EXCEPTION_HANDLING_RETRY_AFTER_MS = 200;
private final ItemService itemService;
@Transactional(readOnly = true)
public void incrementAmount(UUID id, int amount) {
try {
itemService.incrementAmount(id, amount);
} catch (PessimisticLockingFailureException e) {
log.error("Found pessimistic lock exception!", e);
sleep(PESSIMISTIC_LOCKING_EXCEPTION_HANDLING_RETRY_AFTER_MS);
itemService.incrementAmount(id, amount);
}
}
private void sleep(long timeout) {
try {
TimeUnit.MILLISECONDS.sleep(timeout);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
- The InventoryService method incrementAmount(...) implements, for demonstration purposes, a pessimistic locking handling of PessimisticLockingFailureException. It waits 200ms and then retries;
- According to the JPA specification, a LockTimeoutException should not automatically mark the transaction for rollback;
- In that case, it should not be necessary to execute the subordinate service ItemService in an internal transaction;
- Unfortunately, Apache Derby does not conform to this specification and rolls back the transaction.
π Having two locked transactions instead of one for every concurrent thread only for the sake of integration tests may not seem so palatable at first; Nevertheless, this is only valid for "retry" pessimistic locking handling strategy, where you may want to "save" the highest level transaction. For the most "production-ready" pessimistic locking handling strategies you will block only one transaction. For more info read here.
The Test Explained
@SpringBootTest
@ActiveProfiles("test")
//@ActiveProfiles("test-oracle")
@Slf4j
class InventoryServicePessimisticLockingTest {
@Autowired
private ItemRepository itemRepository;
@Autowired
private InventoryService inventoryService;
@SpyBean
private ItemService itemService;
@SpyBean
private CustomizedItemRepositoryContext customizedItemRepositoryContext;
@Autowired
private PlatformTransactionManager transactionManager;
@AfterEach
void tearDown() {
itemRepository.deleteAll();
}
/*
* TEST PESSIMISTIC LOCKING
*/
@Test
void shouldIncrementItemAmount_withoutConcurrency() throws Exception {
assertIncrementItemAmountWithPessimisticLocking(false, false, 2);
}
@Test
void shouldIncrementItemAmount_withinPessimisticLockingConcurrencyWithMinimalLockTimeout() throws Exception {
assertIncrementItemAmountWithPessimisticLocking(true, true, 3);
}
@Test
void shouldIncrementItemAmount_withinPessimisticLockingConcurrencyWithDefaultLockTimeout() throws Exception {
assertIncrementItemAmountWithPessimisticLocking(true, false, 2);
}
private void assertIncrementItemAmountWithPessimisticLocking(
boolean simulatePessimisticLocking,
boolean hasToSetMinimalLockTimeOut,
int expectedNumberOfItemServiceInvocations) throws Exception {
// given
if (hasToSetMinimalLockTimeOut) {
long lockTimeOutInMs = customizedItemRepositoryContext.getMinimalPossibleLockTimeOutInMs();
when(customizedItemRepositoryContext.getLockTimeOutInMsForQueryGetItem()).thenReturn(lockTimeOutInMs);
}
if (hasToSetMinimalLockTimeOut && customizedItemRepositoryContext.isRequiredToSetLockTimeoutForTestsAtStartup()) {
log.info("... set lockTimeOut {} ms through native query at startup ...", customizedItemRepositoryContext.getMinimalPossibleLockTimeOutInMs());
TransactionStatus tx = transactionManager.getTransaction(new DefaultTransactionDefinition());
itemRepository.setLockTimeout(customizedItemRepositoryContext.getMinimalPossibleLockTimeOutInMs());
transactionManager.commit(tx);
}
if (simulatePessimisticLocking) {
insertDelayAtTheEndOfPessimisticLockingSection();
}
final Item srcItem = itemRepository.save(new Item());
// when
final List<Integer> itemAmounts = Arrays.asList(10, 5);
if (simulatePessimisticLocking) {
final ExecutorService executor = Executors.newFixedThreadPool(itemAmounts.size());
for (final int amount : itemAmounts) {
executor.execute(() -> inventoryService.incrementAmount(srcItem.getId(), amount));
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
} else {
for (final int amount : itemAmounts) {
inventoryService.incrementAmount(srcItem.getId(), amount);
}
}
// then
final Item item = itemRepository.findById(srcItem.getId()).get();
assertAll(
() -> assertEquals(15, item.getAmount()),
() -> verify(itemService, times(expectedNumberOfItemServiceInvocations)).incrementAmount(any(UUID.class), anyInt())
);
}
private void insertDelayAtTheEndOfPessimisticLockingSection() {
long delay = customizedItemRepositoryContext.getDelayAtTheEndOfTheQueryForPessimisticLockingTestingInMs();
doAnswer(invocation -> {
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
return null;
}).when(customizedItemRepositoryContext).insertArtificialDealyAtTheEndOfTheQueryForTestsOnly();
}
/*
* TEST SET&GET LOCK TIMEOUT
*/
@Test
void shouldSetAndGetLockTimeOut() {
if (customizedItemRepositoryContext.isRequiredToSetLockTimeoutQueryHint()) {
assertThrows(UnsupportedOperationException.class, () -> itemRepository.setLockTimeout(0));
assertThrows(UnsupportedOperationException.class, () -> itemRepository.getLockTimeout());
return;
}
assertSetLockTimeOut(customizedItemRepositoryContext.getMinimalPossibleLockTimeOutInMs());
assertSetLockTimeOut(TimeUnit.SECONDS.toMillis(2));
assertSetLockTimeOut(TimeUnit.MINUTES.toMillis(2));
assertSetLockTimeOut(TimeUnit.HOURS.toMillis(2));
assertSetLockTimeOut(TimeUnit.DAYS.toMillis(2));
}
private void assertSetLockTimeOut(long expectedMilliseconds) {
TransactionStatus tx = transactionManager.getTransaction(new DefaultTransactionDefinition());
itemRepository.setLockTimeout(expectedMilliseconds);
assertEquals(expectedMilliseconds, itemRepository.getLockTimeout());
transactionManager.commit(tx);
}
}
There are 3 tests for testing the pessimistic locking handling mechanism which assert the amount of invocations of method itemService.incrementAmount(...):
- without concurrency: there should be 2 invocations;
- with 2 concurrent threads setting the minimal possible LockTimeout: there should be 3 invocations, because of the "retry" mechanism;
- with 2 concurrent threads setting the default LockTimeout: there should be 2 invocations.
There is an additional test as well which asserts if the setLockTimeout and getLockTimeout are working correctly. Note that if your provider implements JPA correctly (like Oracle) these methods would throw if invoked UnsupportedOperationException.
π Notice that the test inserts some provider-specific delay through the method "insertDelayAtTheEndOfPessimisticLockingSection" at the end of the query. You need this especially for Oracle and MySQL to simulate the "long" running query which causes the LockTimeoutException. For example in the case of MySQL the minimal possible LockTimeout duration is 1000ms and there is no other choice than to wait long enough - and in this case, the parameter "delayAtTheEndOfTheQueryForPessimisticLockingTestingInMs" has to be set to 2000ms!
π The method "customizedItemRepositoryContext.insertArtificialDealyAtTheEndOfTheQueryForTestsOnly()" in the production code is empty. Only in the test environment will a delay be injected. It is a good practice as well to interrupt the current thread when handling the InterruptedException.
π Notice, as well, that if the test runs longer than 1 minute the Thread Executor will abort it with failure!
executor.awaitTermination(1, TimeUnit.MINUTES);
π Take a look at the usage of @SpyBean annotation in the code. This annotation indirectly uses the real spring component but a Mockito wrapper class allows you to do some verifications and to mock part of it if you need it.
@SpyBean
private ItemService itemService;
...
() -> verify(itemService, times(expectedNumberOfItemServiceInvocations)).incrementAmount(any(UUID.class), anyInt())
...
Test Execution Against The In-Memory Database Apache Derby
@SpringBootTest
@ActiveProfiles("test")
//@ActiveProfiles("test-oracle")
Using the default "test" profile for your integration tests will allow you to run all pessimistic locking handling tests in-memory.
π This is usually the most suitable format for your integration tests because you don't need additional infrastructure for running them in your CI pipeline!
π As explained earlier, you need an in-memory database with good pessimistic locking support (like Apache Derby) to run these tests. If you have used another in-memory database provider (like H2 or HSQLDB), you don't need to migrate these tests to Apache Derby. Just use two different profiles!
Test Execution Against The Production Database Oracle, MySQL or PostgreSQL
@SpringBootTest
//@ActiveProfiles("test")
@ActiveProfiles("test-oracle")
Using the "test-oracle", "test-mysql" or "test-postgresql" profile for your integration tests will allow you to run all pessimistic locking handling tests against your production database.
π This format is suitable for the local environment when you do changes to your pessimistic locking handling. You could check this profile periodically to be sure that all is going to work as you expect. You need this additional "guarantee" because every RDBMS handles pessimistic locking differently!
π If you use "test-oracle", "test-mysql" or "test-postgresql" profile you have to first set up a corresponding database locally. In every GitHub example you will find bash scripts and docker files for starting Oracle, MySQL or PostgreSQL database!
How To Start Oracle, MySQL Or PostgreSQL locally
Be sure that you have docker up&running. After that run the following bash commands to start the corresponding database and to check that the docker container is working:
cd docker
./db-up.sh
docker ps
Summary
- Pessimistic locking is a valuable tool for solving race conditions in high concurrency environment and for synchronization on database-level;
- Having an adequate pessimistic locking handling strategy will boost your performance and stability;
- Be sure your pessimistic locking handling tests are always included in your CI pipeline;
- Make sure you can test periodically your pessimistic locking handling solutions against your production database;
- Testing your pessimistic locking handling will always be a good investment compared to the time spent. Furthermore, it could help you prevent future bugs caused by difficult-to-reproduce race conditions;
- And last, but not least: do "not only working software, but also well-crafted software"... and your clients will adore you! π
π The source code is available here: