From 8b11e76f460667caf014e6731be9bc105031444d Mon Sep 17 00:00:00 2001 From: Eugene <48804404+Regyl@users.noreply.github.com> Date: Sun, 27 Aug 2023 14:01:46 +0300 Subject: [PATCH] feature: Add optimistic offline lock (#1306) (#2551) --- optimistic-offline-lock/README.md | 147 ++++++++++++++++++ optimistic-offline-lock/pom.xml | 52 +++++++ .../java/com/iluwatar/api/UpdateService.java | 18 +++ .../exception/ApplicationException.java | 16 ++ .../main/java/com/iluwatar/model/Card.java | 37 +++++ .../iluwatar/repository/JpaRepository.java | 33 ++++ .../iluwatar/service/CardUpdateService.java | 35 +++++ .../java/com/iluwatar/OptimisticLockTest.java | 60 +++++++ pom.xml | 1 + 9 files changed, 399 insertions(+) create mode 100644 optimistic-offline-lock/README.md create mode 100644 optimistic-offline-lock/pom.xml create mode 100644 optimistic-offline-lock/src/main/java/com/iluwatar/api/UpdateService.java create mode 100644 optimistic-offline-lock/src/main/java/com/iluwatar/exception/ApplicationException.java create mode 100644 optimistic-offline-lock/src/main/java/com/iluwatar/model/Card.java create mode 100644 optimistic-offline-lock/src/main/java/com/iluwatar/repository/JpaRepository.java create mode 100644 optimistic-offline-lock/src/main/java/com/iluwatar/service/CardUpdateService.java create mode 100644 optimistic-offline-lock/src/test/java/com/iluwatar/OptimisticLockTest.java diff --git a/optimistic-offline-lock/README.md b/optimistic-offline-lock/README.md new file mode 100644 index 000000000000..7447a30503c7 --- /dev/null +++ b/optimistic-offline-lock/README.md @@ -0,0 +1,147 @@ +--- +title: Optimistic Offline Lock +category: Concurrency +language: en +tag: +- Data access +--- + +## Intent + +Provide an ability to avoid concurrent changes of one record in relational databases. + +## Explanation + +Each transaction during object modifying checks equation of object's version before start of transaction +and before commit itself. + +**Real world example** +> Since people love money, the best (and most common) example is banking system: +> imagine you have 100$ on your e-wallet and two people are trying to send you 50$ both at a time. +> Without locking, your system will start **two different thread**, each of whose will read your current balance +> and just add 50$. The last thread won't re-read balance and will just rewrite it. +> So, instead 200$ you will have only 150$. + +**In plain words** +> Each transaction during object modifying will save object's last version and check it before saving. +> If it differs, the transaction will be rolled back. + +**Wikipedia says** +> Optimistic concurrency control (OCC), also known as optimistic locking, +> is a concurrency control method applied to transactional systems such as +> relational database management systems and software transactional memory. + +**Programmatic Example** +Let's simulate the case from *real world example*. Imagine we have next entity: + +```java +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Bank card entity. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Card { + + /** + * Primary key. + */ + private long id; + + /** + * Foreign key points to card's owner. + */ + private long personId; + + /** + * Sum of money. + */ + private float sum; + + /** + * Current version of object; + */ + private int version; +} +``` + +Then the correct modifying will be like this: + +```java + +import lombok.RequiredArgsConstructor; + +/** + * Service to update {@link Card} entity. + */ +@RequiredArgsConstructor +public class CardUpdateService implements UpdateService { + + private final JpaRepository cardJpaRepository; + + @Override + @Transactional(rollbackFor = ApplicationException.class) //will roll back transaction in case ApplicationException + public Card doUpdate(Card card, long cardId) { + float additionalSum = card.getSum(); + Card cardToUpdate = cardJpaRepository.findById(cardId); + int initialVersion = cardToUpdate.getVersion(); + float resultSum = cardToUpdate.getSum() + additionalSum; + cardToUpdate.setSum(resultSum); + //Maybe more complex business-logic e.g. HTTP-requests and so on + + if (initialVersion != cardJpaRepository.getEntityVersionById(cardId)) { + String exMessage = String.format("Entity with id %s were updated in another transaction", cardId); + throw new ApplicationException(exMessage); + } + + cardJpaRepository.update(cardToUpdate); + return cardToUpdate; + } +} +``` + +## Applicability + +Since optimistic locking can cause degradation of system's efficiency and reliability due to +many retries/rollbacks, it's important to use it safely. They are useful in case when transactions are not so long +and does not distributed among many microservices, when you need to reduce network/database overhead. + +Important to note that you should not choose this approach in case when modifying one object +in different threads is common situation. + +## Tutorials + +- [Offline Concurrency Control](https://www.baeldung.com/cs/offline-concurrency-control) +- [Optimistic lock in JPA](https://www.baeldung.com/jpa-optimistic-locking) + +## Known uses + +- [Hibernate ORM](https://docs.jboss.org/hibernate/orm/4.3/devguide/en-US/html/ch05.html) + +## Consequences + +**Advantages**: + +- Reduces network/database overhead +- Let to avoid database deadlock +- Improve the performance and scalability of the application + +**Disadvantages**: + +- Increases complexity of the application +- Requires mechanism of versioning +- Requires rollback/retry mechanisms + +## Related patterns + +- [Pessimistic Offline Lock](https://martinfowler.com/eaaCatalog/pessimisticOfflineLock.html) + +## Credits + +- [Source (Martin Fowler)](https://martinfowler.com/eaaCatalog/optimisticOfflineLock.html) +- [Advantages and disadvantages](https://www.linkedin.com/advice/0/what-benefits-drawbacks-using-optimistic) +- [Comparison of optimistic and pessimistic locks](https://www.linkedin.com/advice/0/what-advantages-disadvantages-using-optimistic) \ No newline at end of file diff --git a/optimistic-offline-lock/pom.xml b/optimistic-offline-lock/pom.xml new file mode 100644 index 000000000000..d32c46df4534 --- /dev/null +++ b/optimistic-offline-lock/pom.xml @@ -0,0 +1,52 @@ + + + + 4.0.0 + + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + + optimistic-offline-lock + + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + diff --git a/optimistic-offline-lock/src/main/java/com/iluwatar/api/UpdateService.java b/optimistic-offline-lock/src/main/java/com/iluwatar/api/UpdateService.java new file mode 100644 index 000000000000..b3c29d22f0b9 --- /dev/null +++ b/optimistic-offline-lock/src/main/java/com/iluwatar/api/UpdateService.java @@ -0,0 +1,18 @@ +package com.iluwatar.api; + +/** + * Service for entity update. + * + * @param target entity + */ +public interface UpdateService { + + /** + * Update entity. + * + * @param obj entity to update + * @param id primary key + * @return modified entity + */ + T doUpdate(T obj, long id); +} diff --git a/optimistic-offline-lock/src/main/java/com/iluwatar/exception/ApplicationException.java b/optimistic-offline-lock/src/main/java/com/iluwatar/exception/ApplicationException.java new file mode 100644 index 000000000000..7d12c3350e8b --- /dev/null +++ b/optimistic-offline-lock/src/main/java/com/iluwatar/exception/ApplicationException.java @@ -0,0 +1,16 @@ +package com.iluwatar.exception; + +/** + * Exception happens in application during business-logic execution. + */ +public class ApplicationException extends RuntimeException { + + /** + * Inherited constructor with exception message. + * + * @param message exception message + */ + public ApplicationException(String message) { + super(message); + } +} diff --git a/optimistic-offline-lock/src/main/java/com/iluwatar/model/Card.java b/optimistic-offline-lock/src/main/java/com/iluwatar/model/Card.java new file mode 100644 index 000000000000..b36c779d6ba4 --- /dev/null +++ b/optimistic-offline-lock/src/main/java/com/iluwatar/model/Card.java @@ -0,0 +1,37 @@ +package com.iluwatar.model; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Bank card entity. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Card { + + /** + * Primary key. + */ + private long id; + + /** + * Foreign key points to card's owner. + */ + private long personId; + + /** + * Sum of money. + */ + private float sum; + + /** + * Current version of object. + */ + private int version; +} diff --git a/optimistic-offline-lock/src/main/java/com/iluwatar/repository/JpaRepository.java b/optimistic-offline-lock/src/main/java/com/iluwatar/repository/JpaRepository.java new file mode 100644 index 000000000000..b1d3c240a0ce --- /dev/null +++ b/optimistic-offline-lock/src/main/java/com/iluwatar/repository/JpaRepository.java @@ -0,0 +1,33 @@ +package com.iluwatar.repository; + +/** + * Imitation of Spring's JpaRepository. + * + * @param target database entity + */ +public interface JpaRepository { + + /** + * Get object by it's PK. + * + * @param id primary key + * @return {@link T} + */ + T findById(long id); + + /** + * Get current object version. + * + * @param id primary key + * @return object's version + */ + int getEntityVersionById(long id); + + /** + * Update object. + * + * @param obj entity to update + * @return number of modified records + */ + int update(T obj); +} diff --git a/optimistic-offline-lock/src/main/java/com/iluwatar/service/CardUpdateService.java b/optimistic-offline-lock/src/main/java/com/iluwatar/service/CardUpdateService.java new file mode 100644 index 000000000000..0f67c1e5e85f --- /dev/null +++ b/optimistic-offline-lock/src/main/java/com/iluwatar/service/CardUpdateService.java @@ -0,0 +1,35 @@ +package com.iluwatar.service; + +import com.iluwatar.api.UpdateService; +import com.iluwatar.exception.ApplicationException; +import com.iluwatar.model.Card; +import com.iluwatar.repository.JpaRepository; +import lombok.RequiredArgsConstructor; + +/** + * Service to update {@link Card} entity. + */ +@RequiredArgsConstructor +public class CardUpdateService implements UpdateService { + + private final JpaRepository cardJpaRepository; + + @Override + public Card doUpdate(Card obj, long id) { + float additionalSum = obj.getSum(); + Card cardToUpdate = cardJpaRepository.findById(id); + int initialVersion = cardToUpdate.getVersion(); + float resultSum = cardToUpdate.getSum() + additionalSum; + cardToUpdate.setSum(resultSum); + //Maybe more complex business-logic e.g. HTTP-requests and so on + + if (initialVersion != cardJpaRepository.getEntityVersionById(id)) { + String exMessage = + String.format("Entity with id %s were updated in another transaction", id); + throw new ApplicationException(exMessage); + } + + cardJpaRepository.update(cardToUpdate); + return cardToUpdate; + } +} diff --git a/optimistic-offline-lock/src/test/java/com/iluwatar/OptimisticLockTest.java b/optimistic-offline-lock/src/test/java/com/iluwatar/OptimisticLockTest.java new file mode 100644 index 000000000000..479b5807838e --- /dev/null +++ b/optimistic-offline-lock/src/test/java/com/iluwatar/OptimisticLockTest.java @@ -0,0 +1,60 @@ +package com.iluwatar; + +import com.iluwatar.exception.ApplicationException; +import com.iluwatar.model.Card; +import com.iluwatar.repository.JpaRepository; +import com.iluwatar.service.CardUpdateService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class OptimisticLockTest { + + private CardUpdateService cardUpdateService; + + private JpaRepository cardRepository; + + @BeforeEach + public void setUp() { + cardRepository = Mockito.mock(JpaRepository.class); + cardUpdateService = new CardUpdateService(cardRepository); + } + + @Test + public void shouldNotUpdateEntityOnDifferentVersion() { + int initialVersion = 1; + long cardId = 123L; + Card card = Card.builder() + .id(cardId) + .version(initialVersion) + .sum(123f) + .build(); + when(cardRepository.findById(eq(cardId))).thenReturn(card); + when(cardRepository.getEntityVersionById(Mockito.eq(cardId))).thenReturn(initialVersion + 1); + + Assertions.assertThrows(ApplicationException.class, + () -> cardUpdateService.doUpdate(card, cardId)); + } + + @Test + public void shouldUpdateOnSameVersion() { + int initialVersion = 1; + long cardId = 123L; + Card card = Card.builder() + .id(cardId) + .version(initialVersion) + .sum(123f) + .build(); + when(cardRepository.findById(eq(cardId))).thenReturn(card); + when(cardRepository.getEntityVersionById(Mockito.eq(cardId))).thenReturn(initialVersion); + + cardUpdateService.doUpdate(card, cardId); + + Mockito.verify(cardRepository).update(Mockito.any()); + } +} diff --git a/pom.xml b/pom.xml index 7ae1cdd31edc..2bf8874fa7d6 100644 --- a/pom.xml +++ b/pom.xml @@ -206,6 +206,7 @@ component context-object thread-local-storage + optimistic-offline-lock