Distributed lock for inventory

This commit is contained in:
Kenny Bastani
2017-01-11 20:35:15 -08:00
parent 91310fdbc0
commit 9d13169d80
16 changed files with 171 additions and 33 deletions

View File

@@ -27,6 +27,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -38,6 +38,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -6,6 +6,10 @@ mysql:
- MYSQL_ROOT_PASSWORD=dbpass - MYSQL_ROOT_PASSWORD=dbpass
- MYSQL_DATABASE=dev] - MYSQL_DATABASE=dev]
net: host net: host
redis:
container_name: redis
image: redis:latest
net: host
rabbit: rabbit:
container_name: rabbit container_name: rabbit
image: rabbitmq:3-management image: rabbitmq:3-management

View File

@@ -27,6 +27,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -32,6 +32,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -27,6 +27,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -32,6 +32,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:

View File

@@ -31,6 +31,15 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.2.2</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>

View File

@@ -0,0 +1,15 @@
package demo.config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
return Redisson.create();
}
}

View File

@@ -35,19 +35,14 @@ public class ReserveInventory extends Action<Inventory> {
public BiFunction<Inventory, Long, Inventory> getFunction() { public BiFunction<Inventory, Long, Inventory> getFunction() {
return (inventory, reservationId) -> { return (inventory, reservationId) -> {
Assert.isTrue(inventory.getStatus() == InventoryStatus.RESERVATION_PENDING, Assert.isTrue(inventory.getStatus() == InventoryStatus.RESERVATION_CONNECTED,
"Inventory must be in a reservation pending state"); "Inventory must be in a reservation connected state");
Assert.isTrue(inventory.getReservation() == null, Assert.isTrue(inventory.getReservation() == null,
"There is already a reservation attached to the inventory"); "There is already a reservation attached to the inventory");
Reservation reservation = reservationService.get(reservationId); Reservation reservation = reservationService.get(reservationId);
Assert.notNull(reservation, "Reserve inventory failed, the reservation does not exist"); Assert.notNull(reservation, "Reserve inventory failed, the reservation does not exist");
inventory.setReservation(reservation);
inventory.setStatus(RESERVATION_CONNECTED);
inventory = inventoryService.update(inventory);
try { try {
// Trigger the reservation connected event // Trigger the reservation connected event
inventory.sendAsyncEvent(new InventoryEvent(InventoryEventType.RESERVATION_CONNECTED, inventory)); inventory.sendAsyncEvent(new InventoryEvent(InventoryEventType.RESERVATION_CONNECTED, inventory));

View File

@@ -34,7 +34,7 @@ public class Inventory extends AbstractEntity<InventoryEvent, Long> {
private Reservation reservation; private Reservation reservation;
@JsonIgnore @JsonIgnore
@ManyToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) @ManyToOne(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Warehouse warehouse; private Warehouse warehouse;
public Inventory() { public Inventory() {
@@ -111,9 +111,16 @@ public class Inventory extends AbstractEntity<InventoryEvent, Long> {
*/ */
@Override @Override
public Link getId() { public Link getId() {
return linkTo(InventoryController.class) Link link;
try {
link = linkTo(InventoryController.class)
.slash("inventory") .slash("inventory")
.slash(getIdentity()) .slash(getIdentity())
.withSelfRel(); .withSelfRel();
} catch (Exception ex) {
link = new Link("http://warehouse-service/v1/inventory/" + id, "self");
}
return link;
} }
} }

View File

@@ -3,16 +3,23 @@ package demo.inventory.domain;
import demo.domain.Service; import demo.domain.Service;
import demo.inventory.repository.InventoryRepository; import demo.inventory.repository.InventoryRepository;
import demo.reservation.domain.Reservation; import demo.reservation.domain.Reservation;
import org.springframework.transaction.annotation.Transactional; import org.apache.log4j.Logger;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import java.util.concurrent.TimeUnit;
@org.springframework.stereotype.Service @org.springframework.stereotype.Service
public class InventoryService extends Service<Inventory, Long> { public class InventoryService extends Service<Inventory, Long> {
private final Logger log = Logger.getLogger(InventoryService.class);
private final InventoryRepository inventoryRepository; private final InventoryRepository inventoryRepository;
private final RedissonClient redissonClient;
public InventoryService(InventoryRepository inventoryRepository) { public InventoryService(InventoryRepository inventoryRepository, RedissonClient redissonClient) {
this.inventoryRepository = inventoryRepository; this.inventoryRepository = inventoryRepository;
this.redissonClient = redissonClient;
} }
/** /**
@@ -79,19 +86,44 @@ public class InventoryService extends Service<Inventory, Long> {
* @param reservation is the reservation to connect to the inventory * @param reservation is the reservation to connect to the inventory
* @return the first available inventory in the warehouse or null * @return the first available inventory in the warehouse or null
*/ */
@Transactional
public Inventory findAvailableInventory(Reservation reservation) { public Inventory findAvailableInventory(Reservation reservation) {
Assert.notNull(reservation.getWarehouse(), "Reservation must be connected to a warehouse"); Assert.notNull(reservation.getWarehouse(), "Reservation must be connected to a warehouse");
Assert.notNull(reservation.getProductId(), "Reservation must contain a valid product identifier"); Assert.notNull(reservation.getProductId(), "Reservation must contain a valid product identifier");
Inventory inventory = inventoryRepository Boolean reserved = false;
.findFirstInventoryByWarehouseIdAndProductIdAndStatus(reservation.getWarehouse() Inventory inventory = null;
.getIdentity(), reservation.getProductId(), InventoryStatus.RESERVATION_PENDING)
.orElse(null);
while (!reserved) {
inventory = inventoryRepository
.findFirstInventoryByWarehouseIdAndProductIdAndStatus(reservation.getWarehouse()
.getIdentity(), reservation.getProductId(), InventoryStatus.RESERVATION_PENDING);
if (inventory != null) { if (inventory != null) {
// Acquire lock
RLock inventoryLock = redissonClient
.getLock(String.format("inventory_%s", inventory.getIdentity().toString()));
Boolean lock = false;
try {
lock = inventoryLock.tryLock(30, 5000, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
log.error("Interrupted while acquiring lock on inventory", e);
}
if (lock) {
inventory.setStatus(InventoryStatus.RESERVATION_CONNECTED);
inventory = update(inventory);
// Reserve the inventory // Reserve the inventory
inventory = inventory.reserve(reservation.getIdentity()); inventory = inventory.reserve(reservation.getIdentity());
inventoryLock.unlock();
}
reserved = lock;
} else {
reserved = true;
}
} }
return inventory; return inventory;

View File

@@ -5,9 +5,7 @@ import demo.inventory.domain.InventoryStatus;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface InventoryRepository extends JpaRepository<Inventory, Long> { public interface InventoryRepository extends JpaRepository<Inventory, Long> {
Optional<Inventory> findFirstInventoryByWarehouseIdAndProductIdAndStatus(@Param("warehouseId") Long warehouseId, Inventory findFirstInventoryByWarehouseIdAndProductIdAndStatus(@Param("warehouseId") Long warehouseId,
@Param("productId") String productId, @Param("status") InventoryStatus status); @Param("productId") String productId, @Param("status") InventoryStatus status);
} }

View File

@@ -1,6 +1,13 @@
spring: spring:
profiles: profiles:
active: development active: development
server:
port: 0
events:
worker: http://warehouse-worker/v1/events
---
spring:
profiles: development
cloud: cloud:
stream: stream:
bindings: bindings:
@@ -13,19 +20,29 @@ spring:
inventory: inventory:
contentType: 'application/json' contentType: 'application/json'
destination: inventory destination: inventory
server:
port: 0
events:
worker: http://warehouse-worker/v1/events
---
spring:
profiles: development
--- ---
spring: spring:
profiles: docker profiles: docker
rabbitmq: rabbitmq:
host: ${DOCKER_IP:192.168.99.100} host: ${DOCKER_IP:192.168.99.100}
port: 5672 port: 5672
datasource:
url: jdbc:h2:mem:AZ;MVCC=TRUE
redis:
host: ${DOCKER_IP:192.168.99.100}
port: 6379
cloud:
stream:
bindings:
warehouse:
contentType: 'application/json'
destination: warehouse
reservation:
contentType: 'application/json'
destination: reservation
inventory:
contentType: 'application/json'
destination: inventory
eureka: eureka:
client: client:
service-url: service-url:
@@ -33,16 +50,32 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring:
profiles: test profiles: test
redis:
host: localhost
port: 6379
eureka: eureka:
client: client:
enabled: false enabled: false
--- ---
spring: spring:
profiles: cloud profiles: cloud
cloud:
stream:
bindings:
warehouse:
contentType: 'application/json'
destination: warehouse
reservation:
contentType: 'application/json'
destination: reservation
inventory:
contentType: 'application/json'
destination: inventory
eureka: eureka:
instance: instance:
hostname: ${vcap.application.uris[0]:localhost} hostname: ${vcap.application.uris[0]:localhost}

View File

@@ -2,6 +2,7 @@ package demo;
import demo.inventory.domain.Inventory; import demo.inventory.domain.Inventory;
import demo.inventory.domain.InventoryService; import demo.inventory.domain.InventoryService;
import demo.inventory.domain.InventoryStatus;
import demo.inventory.event.InventoryEventService; import demo.inventory.event.InventoryEventService;
import demo.inventory.repository.InventoryRepository; import demo.inventory.repository.InventoryRepository;
import demo.reservation.domain.Reservation; import demo.reservation.domain.Reservation;
@@ -22,6 +23,7 @@ import org.springframework.test.context.junit4.SpringRunner;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -103,4 +105,37 @@ public class WarehouseServiceTests {
} }
@Test
public void asyncInventoryReservationTest() throws Exception {
Warehouse warehouse = new Warehouse();
warehouse = warehouseRepository.save(warehouse);
Inventory inventory1 = new Inventory();
inventory1.setProductId("SKU-001");
inventory1.setWarehouse(warehouse);
inventory1.setStatus(InventoryStatus.RESERVATION_PENDING);
Inventory inventory2 = new Inventory();
inventory2.setProductId("SKU-001");
inventory2.setWarehouse(warehouse);
inventory2.setStatus(InventoryStatus.RESERVATION_PENDING);
List<Inventory> inventories = inventoryRepository.save(Arrays.asList(inventory1, inventory2));
Reservation reservation1 = new Reservation();
reservation1.setProductId("SKU-001");
reservation1.setWarehouse(warehouse);
Reservation reservation2 = new Reservation();
reservation2.setProductId("SKU-001");
reservation2.setWarehouse(warehouse);
List<Reservation> reservations = reservationRepository.save(Arrays.asList(reservation1, reservation2));
List<Inventory> reservedInventory = reservations.parallelStream()
.map(a -> inventoryService.findAvailableInventory(a)).collect(Collectors
.toList());
assertThat(reservedInventory).isNotEmpty();
assertThat(reservedInventory.size()).isEqualTo(2);
assertThat(reservedInventory.get(0)).isNotSameAs(reservedInventory.get(1));
}
} }

View File

@@ -10,18 +10,21 @@ spring:
group: warehouse-group group: warehouse-group
consumer: consumer:
durableSubscription: true durableSubscription: true
concurrency: 5
reservation: reservation:
contentType: 'application/json' contentType: 'application/json'
destination: reservation destination: reservation
group: reservation-group group: reservation-group
consumer: consumer:
durableSubscription: true durableSubscription: true
concurrency: 5
inventory: inventory:
contentType: 'application/json' contentType: 'application/json'
destination: inventory destination: inventory
group: inventory-group group: inventory-group
consumer: consumer:
durableSubscription: true durableSubscription: true
concurrency: 5
server: server:
port: 0 port: 0
--- ---
@@ -40,6 +43,7 @@ eureka:
registryFetchIntervalSeconds: 5 registryFetchIntervalSeconds: 5
instance: instance:
hostname: ${DOCKER_IP:192.168.99.100} hostname: ${DOCKER_IP:192.168.99.100}
instance-id: ${spring.application.name}:${random.int}
leaseRenewalIntervalInSeconds: 5 leaseRenewalIntervalInSeconds: 5
--- ---
spring: spring: