Last active
June 14, 2024 14:53
-
-
Save susimsek/f6dcdb01701107530bdff7873124632e to your computer and use it in GitHub Desktop.
Spring Data JPA Optimistic Lock
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
server: | |
port: 8080 | |
spring: | |
datasource: | |
url: jdbc:h2:mem:accountdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE; | |
username: datademo | |
password: root | |
hikari: | |
maximum-pool-size: 30 | |
minimum-idle: 1 | |
pool-name: Hikari | |
auto-commit: false | |
h2: | |
console: | |
enabled: true | |
jpa: | |
hibernate: | |
ddl-auto: none | |
show-sql: true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.susimsek.springdatademo.config; | |
import java.time.Clock; | |
import java.time.Instant; | |
import java.util.Optional; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.context.annotation.Bean; | |
import org.springframework.context.annotation.Configuration; | |
import org.springframework.data.auditing.DateTimeProvider; | |
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; | |
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; | |
import org.springframework.transaction.annotation.EnableTransactionManagement; | |
@Configuration(proxyBeanMethods = false) | |
@EnableJpaRepositories(basePackages = "io.github.susimsek.springdatademo.repository") | |
@EnableJpaAuditing( | |
dateTimeProviderRef = "dateTimeProvider") | |
@EnableTransactionManagement | |
@Slf4j | |
public class DatabaseConfig { | |
@Bean | |
public Clock clock() { | |
return Clock.systemDefaultZone(); | |
} | |
@Bean | |
public DateTimeProvider dateTimeProvider(Clock clock) { | |
return () -> Optional.of(Instant.now(clock)); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.susimsek.springdatademo.entity; | |
import jakarta.persistence.Entity; | |
import jakarta.persistence.GeneratedValue; | |
import jakarta.persistence.GenerationType; | |
import jakarta.persistence.Id; | |
import jakarta.persistence.Version; | |
import lombok.AllArgsConstructor; | |
import lombok.Getter; | |
import lombok.NoArgsConstructor; | |
import lombok.Setter; | |
@Entity | |
@Getter | |
@Setter | |
@NoArgsConstructor | |
@AllArgsConstructor | |
public class Product { | |
@Id | |
@GeneratedValue(strategy = GenerationType.IDENTITY) | |
private Long id; | |
private String name; | |
private Double price; | |
@Version | |
private int version; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.susimsek.springdatademo.repository; | |
import io.github.susimsek.springdatademo.entity.Product; | |
import jakarta.persistence.LockModeType; | |
import java.util.Optional; | |
import org.springframework.data.jpa.repository.JpaRepository; | |
import org.springframework.data.jpa.repository.Lock; | |
import org.springframework.lang.NonNull; | |
public interface ProductRepository extends JpaRepository<Product, Long> { | |
@Lock(LockModeType.OPTIMISTIC) | |
@NonNull | |
Optional<Product> findById(@NonNull Long id); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.susimsek.springdatademo.service; | |
import io.github.susimsek.springdatademo.entity.Product; | |
import io.github.susimsek.springdatademo.repository.ProductRepository; | |
import lombok.RequiredArgsConstructor; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.orm.ObjectOptimisticLockingFailureException; | |
import org.springframework.stereotype.Service; | |
import org.springframework.transaction.annotation.Transactional; | |
@Service | |
@Slf4j | |
@RequiredArgsConstructor | |
public class ProductService { | |
private final ProductRepository productRepository; | |
@Transactional | |
public void updateProductPrice(Long productId, Double newPrice) { | |
int attempt = 0; | |
boolean success = false; | |
while (attempt < 3 && !success) { | |
try { | |
attempt++; | |
log.info("Attempt {}: Updating product price to {}", attempt, newPrice); | |
Product product = productRepository.findById(productId) | |
.orElseThrow(() -> new RuntimeException("Product not found")); | |
// Perform the update | |
product.setPrice(newPrice); | |
productRepository.save(product); // Optimistic locking is handled by Spring Data JPA | |
success = true; | |
log.info("Successfully updated product price to {}", newPrice); | |
} catch (ObjectOptimisticLockingFailureException e) { | |
log.warn("Optimistic lock exception on attempt {}: {}", attempt, e.getMessage()); | |
// If max attempts reached, log and exit without throwing exception | |
if (attempt == 3) { | |
log.error("Failed to update product after retries: {}", productId); | |
return; // Exit method without throwing exception | |
} | |
// Wait before retrying | |
try { | |
retryDelay(); | |
} catch (InterruptedException ex) { | |
Thread.currentThread().interrupt(); | |
log.warn("Thread interrupted during retry delay", ex); | |
} | |
} | |
} | |
} | |
private void retryDelay() throws InterruptedException { | |
Thread.sleep(100); // Retry delay | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.susimsek.springdatademo.service; | |
import static org.assertj.core.api.Assertions.assertThat; | |
import io.github.susimsek.springdatademo.entity.Product; | |
import io.github.susimsek.springdatademo.repository.ProductRepository; | |
import jakarta.persistence.EntityManager; | |
import jakarta.persistence.PersistenceContext; | |
import java.util.concurrent.ExecutorService; | |
import java.util.concurrent.Executors; | |
import java.util.concurrent.TimeUnit; | |
import java.util.concurrent.atomic.AtomicReference; | |
import org.junit.jupiter.api.BeforeEach; | |
import org.junit.jupiter.api.DisplayName; | |
import org.junit.jupiter.api.Test; | |
import org.junit.jupiter.api.TestInstance; | |
import org.junit.jupiter.api.parallel.Execution; | |
import org.junit.jupiter.api.parallel.ExecutionMode; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; | |
import org.springframework.context.annotation.Import; | |
import org.springframework.orm.ObjectOptimisticLockingFailureException; | |
@DataJpaTest | |
@Import(ProductService.class) | |
@TestInstance(TestInstance.Lifecycle.PER_CLASS) | |
@Execution(ExecutionMode.CONCURRENT) | |
class ProductServiceTest { | |
@Autowired | |
private ProductRepository productRepository; | |
@Autowired | |
private ProductService productService; | |
@PersistenceContext | |
private EntityManager entityManager; | |
@BeforeEach | |
void setUp() { | |
Product product = new Product(); | |
product.setName("Test Product"); | |
product.setPrice(50.0); | |
productRepository.save(product); | |
} | |
@Test | |
@DisplayName("Test concurrent updates with optimistic locking exception") | |
void testOptimisticLockingException() throws InterruptedException { | |
Long productId = productRepository.findAll().get(0).getId(); | |
Double newPrice1 = 100.0; | |
Double newPrice2 = 150.0; | |
AtomicReference<Exception> exception1 = new AtomicReference<>(); | |
AtomicReference<Exception> exception2 = new AtomicReference<>(); | |
Runnable task1 = () -> { | |
try { | |
productService.updateProductPrice(productId, newPrice1); | |
} catch (Exception e) { | |
exception1.set(e); | |
} | |
}; | |
Runnable task2 = () -> { | |
try { | |
productService.updateProductPrice(productId, newPrice2); | |
} catch (Exception e) { | |
exception2.set(e); | |
} | |
}; | |
ExecutorService executorService = Executors.newFixedThreadPool(2); | |
executorService.submit(task1); | |
executorService.submit(task2); | |
executorService.shutdown(); | |
executorService.awaitTermination(10, TimeUnit.SECONDS); | |
// Clear the EntityManager to avoid returning cached entities | |
entityManager.clear(); | |
// Fetch the updated product | |
Product updatedProduct = productRepository.findById(productId).orElseThrow(); | |
System.out.println("Updated price: " + updatedProduct.getPrice()); | |
assertThat(updatedProduct.getPrice()).isIn(newPrice1, newPrice2); | |
// Verify that at least one thread encountered an optimistic lock exception | |
if (exception1.get() != null) { | |
assertThat(exception1.get()).isInstanceOf(ObjectOptimisticLockingFailureException.class); | |
} else if (exception2.get() != null) { | |
assertThat(exception2.get()).isInstanceOf(ObjectOptimisticLockingFailureException.class); | |
} else { | |
throw new AssertionError("Expected at least one thread to encounter an ObjectOptimisticLockingFailureException"); | |
} | |
} | |
@Test | |
@DisplayName("Test successful update without contention") | |
void testSuccessfulUpdate() { | |
Long productId = productRepository.findAll().get(0).getId(); | |
Double newPrice = 200.0; | |
productService.updateProductPrice(productId, newPrice); | |
// Clear the EntityManager to avoid returning cached entities | |
// Fetch the updated product | |
Product updatedProduct = productRepository.findById(productId).orElseThrow(); | |
System.out.println("Updated price: " + updatedProduct.getPrice()); | |
assertThat(updatedProduct.getPrice()).isEqualTo(newPrice); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment