I’m tryng to get more into Optimistic Lock and in detail, verifyng with a test, that my implementation it’s correct. The implementation it’s with Java 11, SpringBoot and Maven interacting with a simple Sql DB.
The scenario i want to test it’s when, from two different applications, i’m tryng to update a single attribute ( handled with the @Version of jpa ) of the same entity record simultaneously. What i did was creating a simple project and do a test where in two different threads ( launched at the same time with a countdown ) i was mocking up two calls to different methods that update that attribute; I handled this services differently cause in one i just want to inform the user that the Object it’s already been updated, while in the other one i want to retry the update “n” times when i encounter that specific exception.
***For using the @Retryable i inserted in the pom this dependencyes:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
and in the main this annotation : @EnableRetry***
This is the entity:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name="product")
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="product_id")
private Integer productId;
@Column(name="name")
private String name;
@Column(name="version")
@Version
private Long version;
}
This is my controller:
@RestController
@RequestMapping("/api/product")
public class ProductAPIController {
@Autowired
private IProductService productService;
@PutMapping("/new-name-one")
public ResponseEntity<ProductDTO> updateProductOrFail(
@RequestBody @Valid ProductDTO productDTO)
throws Exception {
if(productDTO.getName() == null) {
throw new ValidationException("The name has to be present");
}
try {
ProductDTO updatedProduct = productService.updateProductOrFail(ProductDTO);
return new ResponseEntity<ProductDTO>(updatedProduct, HttpStatus.OK);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
throw new OptimisticLockException("The Product has been updated by another transaction", e);
}
}
@PutMapping("/new-name-two")
public ResponseEntity<ProductDTO> updateProductOtherwiseRetry(
@RequestBody @Valid ProductDTO productDTO)
throws Exception {
if(productDTO.getName() == null) {
throw new ValidationException("The name has to be present");
}
return new ResponseEntity<ProductDTO>(productService.updateProductOtherwiseRetry(productDTO), HttpStatus.OK);
}```
I will skip the service Interface and go directly to the two methods inside the service:
@Service
public class ProductService implements IProductService {
@Autowired
ProductRepository productRepository;
@Autowired
ModelMapper modelMapper;
@Override
@Transactional
public ProductDTO updateProductOrFail(ProductDTO productDTO) throws ServiceException {
ProductEntity product = productRepository.findById(productDTO.getProductId())
.orElseThrow(() -> new ServiceException("Product not found"));
product.setName(productDTO.getName());
productRepository.save(product);
return modelMapper.map(product, ProductDTO.class);
}
@Override
@Transactional
@Retryable(value = {OptimisticLockException.class,
OptimisticLockingFailureException.class,
ObjectOptimisticLockingFailureException.class},
maxAttempts = 2,
backoff = @Backoff(delay = 1000))
public ProductDTO updateProductOtherwiseRetry(ProductDTO productDTO) throws ServiceException {
ProductEntity product = userStoryRepository.findById(productDTO.getUserStoryId())
.orElseThrow(() -> new ServiceException("Product not found"));
product.setName(productDTO.getName());
productRepository.save(product);
//log.warn("The Product has been updated by another transaction try again...");
return modelMapper.map(product, ProductDTO.class);
}
Inside my Controller class Test this is the implementation of the method:
@Test
void havingTwoConcurrencyThread_whenUpdatingTheNameOfAProduct_ThenOptimisticLockExceptionHasToBeThrown() throws Exception {
ProductEntity product = new ProductEntity();
product.setUserStoryId(null);
product.setName(“New Product”);
productRepository.save(product);
CountDownLatch latch = new CountDownLatch(1);
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
ProductDTO productOneToUpdate = new ProductDTO();
productOneToUpdate.setProductId(1);
productOneToUpdate.setName("Product One");
try {
latch.await();
log.info("Thread 1 has started...");
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.put(LINK + "/new-name-one")
.contentType(MediaType.APPLICATION_JSON)
.content(gson.toJson(productOneToUpdate)))
.andExpect(status().is2xxSuccessful()).andReturn();
log.info("Thread 1 has performed the call");
ProductDTO productChanged = gson.fromJson(result.getResponse().getContentAsString(), new TypeToken<ProductDTO>() {
}.getType());
assertEquals("Product One", productChanged.getName());
}catch (Exception e){
log.error("Exception in Thread 1", e);
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
ProductDTO productTwoToUpdate = new ProductDTO();
productTwoToUpdate.setProductId(1);
productTwoToUpdate.setName("Product Two");
try {
latch.await();
log.info("Thread 2 has started...");
mockMvc.perform(MockMvcRequestBuilders.put(LINK + "/new-name-two")
.contentType(MediaType.APPLICATION_JSON)
.content(gson.toJson(productTwoToUpdate)))
.andExpect(status().isConflict());
log.info("Thread 2 has performed the call");
} catch (Exception e) {
log.error("Exception in Thread 2", e);
assertTrue(e.getCause() instanceof ObjectOptimisticLockingFailureException ||
e.getCause() instanceof OptimisticLockException);
}
}
});
t1.start();
t2.start();
latch.countDown();
t1.join();
t2.join();
}```
Maybe i didn’t give you enough sample code, but i can tell you that my project has succesfully been built, and inside my TestClass i have other test ( runned as Junit ) passed succesfully. When i run this test that’s the result of the console:
2024-06-13 18:19:33.669 INFO 29940 --- [ Thread-5] c.p.c.controller.ProductControllerIT : Thread 2 has started...
2024-06-13 18:19:33.669 INFO 29940 --- [ Thread-4] c.p.c.controller.ProductControllerIT : Thread 1 has started...
2024-06-13 18:19:33.887 INFO 29940 --- [ Thread-4] o.h.e.j.b.internal.AbstractBatchImpl : HHH000010: On release of batch it still contained JDBC statements
2024-06-13 18:19:33.897 ERROR 29940 --- [ Thread-4] c.p.c.c.ControllerExceptionHandler : Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:318) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:743) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:654) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:762) ~[spring-aop-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:707) ~[spring-aop-5.3.30.jar:5.3.30]
at com.example.project.service.ProductService$$EnhancerBySpringCGLIB$$******.updateProductOtherwiseRetry(<generated>) ~[classes/:na]
at com.example.project.controller.ProductAPIController.updateProductOrFail(ProductAPIController.java:80) ~[classes/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na] [...]
Caused by: org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
at org.hibernate.jdbc.Expectations$BasicExpectation.checkBatched(Expectations.java:67) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.jdbc.Expectations$BasicExpectation.verifyOutcome(Expectations.java:54) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.jdbc.batch.internal.NonBatchingBatch.addToBatch(NonBatchingBatch.java:47) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:3571) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.updateOrInsert(AbstractEntityPersister.java:3438) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:3870) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.action.internal.EntityUpdateAction.execute(EntityUpdateAction.java:202) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:604) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.spi.ActionQueue.lambda$executeActions$1(ActionQueue.java:478) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:684) ~[na:na]
at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:475) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:344) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1407) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:489) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:3303) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2438) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:449) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:183) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.access$300(JdbcResourceLocalTransactionCoordinatorImpl.java:40) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:281) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562) ~[spring-orm-5.3.30.jar:5.3.30]
... 82 common frames omitted
Exception in thread "Thread-4" Exception in thread "Thread-5" java.lang.AssertionError: Range for response status value 500 expected:<SUCCESSFUL> but was:<SERVER_ERROR>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$is2xxSuccessful$3(StatusResultMatchers.java:78)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:214)
at com.example.project.controller.ProductyControllerIT$5.run(ProductControllerIT.java:476)
at java.base/java.lang.Thread.run(Thread.java:834)
java.lang.AssertionError: Status expected:<409> but was:<200>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$9(StatusResultMatchers.java:627)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:214)
at com.example.project.controller.ProductyControllerIT$6.run(ProductControllerIT.java:504)
at java.base/java.lang.Thread.run(Thread.java:834)
2024-06-13 18:19:33.987 INFO 29940 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-06-13 18:19:33.988 INFO 29940 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2024-06-13 18:19:33.997 INFO 29940 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2024-06-13 18:19:34.004 INFO 29940 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
The first mock up call in the first thread it’s the call to the services that has the @Retryable annotation so i expect that that’s the one that has to be retryed and give me a 200, while the second one it’s the one that should fail.
What i read from the console is that the assertion i have in the first one failed, and give me a 500 Code, while the second one succeeded instead of giving me a Conflict Code.
I don’t know if my Test it’s formally correct and cover the test case where, with two concurrent transaction, i can let prevail the one i want. I also am not completely sure if that’s how someone has to implement correctly the Optimistick Lock. There are a lot of things that i have to learn, but i just wanted to get my hands dirty and tryng to implement it.
Also, how can i correctly verify if this works or not?