Testing Optimistic Locking Handling with Spring Boot and JPA

June 9, 2020

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

case-conc-transaction-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:

Case2: Concurrent Long Conversations Problem

case-conc-long-conversation-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:

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

case-conc-transaction-solution

Case2: Concurrent Long Conversations Solution

case-conc-long-conversation-solution

🔔 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

🔔 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:

Not One-Size-Fits-All solution

There are a variety of concurrency problems, which could not be solved with optimistic locking. For example:

In that case there are alternative handling strategies. Some of them are:

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:

Implement Your Entities

Base Entity

@Setter
@Getter
@MappedSuperclass
public class BaseEntity {

    @Version
    private Long version;
}

Item

@Data
@EqualsAndHashCode(callSuper = false)
@Entity
public class Item extends BaseEntity {

    @Id
    private String id = UUID.randomUUID().toString();

    private int amount = 0;
}

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);
    }

}

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);
        }
    }

}

🔔 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:

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:

Why, in case of Optimistic Locking Handling, the method incrementAmout is called 3 times?

In our scenario it means that:

Now you could notice that:

Summary

🔔 This example is available at GitHub.

About the author: Andrey Zahariev Stoev

Loves software craftsmanship and systems thinking. Passionate about travel, languages and cultural diversity exploration.

Comments
Join us