diff --git a/HotelApp/CONCURRENCY_LOCKING_DESIGN.md b/HotelApp/CONCURRENCY_LOCKING_DESIGN.md new file mode 100644 index 000000000..35f947ab6 --- /dev/null +++ b/HotelApp/CONCURRENCY_LOCKING_DESIGN.md @@ -0,0 +1,478 @@ +# Concurrency Locking Design for Hotel Booking System + +## Overview + +When running multiple instances of the hotel booking application, we need to handle concurrency issues to prevent double booking of rooms. This document outlines different locking strategies to ensure data consistency and prevent race conditions. + +## Problem Statement + +Multiple users booking the same room simultaneously can cause: +- Double booking of the same room +- Data inconsistency +- Race conditions between application instances +- Loss of booking data + +## Locking Strategies + +## 1. Optimistic Locking + +### **Concept:** +- Assumes conflicts are rare +- Uses version numbers/timestamps to detect conflicts +- Allows concurrent reads, detects conflicts at commit time +- Retries failed operations + +### **Implementation Strategy:** + +**A. Version-based Optimistic Locking:** +```java +@Entity +public class Room { + @Version + private Long version; // JPA will auto-increment on updates + + private Boolean available; + // other fields... +} + +@Entity +public class Booking { + @Version + private Long version; + + @ManyToOne + private Room room; + // other fields... +} +``` + +**B. Service Layer with Retry Logic:** +```java +@Service +@Transactional +public class HotelService { + + @Retryable(value = {OptimisticLockException.class}, maxAttempts = 3) + public Booking createBooking(Long roomId, ...) { + // 1. Read room with current version + Room room = roomRepository.findById(roomId); + + // 2. Check availability + if (!room.getAvailable()) { + throw new RoomNotAvailableException(); + } + + // 3. Create booking and update room + room.setAvailable(false); + roomRepository.save(room); // JPA checks version here + + return bookingRepository.save(new Booking(...)); + // If version changed, OptimisticLockException thrown -> retry + } +} +``` + +**C. Custom Optimistic Lock with Timestamp:** +```java +@Entity +public class Room { + @Column(name = "last_modified") + private LocalDateTime lastModified; + + @PreUpdate + @PrePersist + public void updateTimestamp() { + lastModified = LocalDateTime.now(); + } +} + +// In service: +public Booking createBookingWithTimestamp(Long roomId, LocalDateTime expectedLastModified, ...) { + Room room = roomRepository.findById(roomId); + + if (!room.getLastModified().equals(expectedLastModified)) { + throw new OptimisticLockException("Room was modified by another user"); + } + + // Proceed with booking... +} +``` + +### **Pros:** +- High throughput and concurrency +- No blocking of other transactions +- Simple to implement with JPA +- Good for low-contention scenarios + +### **Cons:** +- Requires retry logic +- May cause user frustration if retries fail +- Not suitable for high-contention scenarios + +## 2. Pessimistic Locking + +### **Concept:** +- Assumes conflicts are common +- Acquires exclusive locks immediately +- Blocks other transactions until lock is released +- Prevents conflicts but reduces concurrency + +### **Implementation Strategy:** + +**A. JPA Pessimistic Locking:** +```java +@Repository +public interface RoomRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM Room r WHERE r.id = :id") + Optional findByIdWithLock(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("SELECT r FROM Room r WHERE r.available = true") + List findAvailableRoomsWithReadLock(); +} +``` + +**B. Service with Pessimistic Lock:** +```java +@Service +@Transactional +public class HotelService { + + public Booking createBookingWithLock(Long roomId, ...) { + // 1. Acquire exclusive lock on room + Room room = roomRepository.findByIdWithLock(roomId) + .orElseThrow(() -> new RoomNotFoundException()); + + // 2. Only this transaction can modify room now + if (!room.getAvailable()) { + throw new RoomNotAvailableException(); + } + + // 3. Update room and create booking + room.setAvailable(false); + roomRepository.save(room); + + return bookingRepository.save(new Booking(...)); + // Lock released when transaction commits + } +} +``` + +**C. Database-level Locking:** +```java +@Repository +public class RoomRepositoryImpl { + + @PersistenceContext + private EntityManager entityManager; + + public Room lockRoomForBooking(Long roomId) { + return entityManager + .createQuery("SELECT r FROM Room r WHERE r.id = :id", Room.class) + .setParameter("id", roomId) + .setLockMode(LockModeType.PESSIMISTIC_FORCE_INCREMENT) + .getSingleResult(); + } +} +``` + +### **Lock Types:** +- `PESSIMISTIC_READ`: Shared lock, allows other reads +- `PESSIMISTIC_WRITE`: Exclusive lock, blocks all access +- `PESSIMISTIC_FORCE_INCREMENT`: Like WRITE but increments version + +### **Pros:** +- Strong consistency guarantees +- No retry logic needed +- Prevents all conflicts +- Good for high-contention scenarios + +### **Cons:** +- Reduced concurrency +- Potential for deadlocks +- Lower throughput +- Lock timeout issues + +## 3. Hybrid Approach (Recommended) + +### **Strategy:** +Combine both approaches based on conflict probability: + +```java +@Service +public class HotelService { + + // For high-contention rooms (popular rooms) + @Transactional + public Booking createBookingPessimistic(Long roomId, ...) { + Room room = roomRepository.findByIdWithLock(roomId); + return doBooking(room, ...); + } + + // For low-contention rooms + @Transactional + @Retryable(value = OptimisticLockException.class, maxAttempts = 3) + public Booking createBookingOptimistic(Long roomId, ...) { + Room room = roomRepository.findById(roomId); + return doBooking(room, ...); + } + + // Route based on room popularity + public Booking createBooking(Long roomId, ...) { + if (isHighContentionRoom(roomId)) { + return createBookingPessimistic(roomId, ...); + } else { + return createBookingOptimistic(roomId, ...); + } + } + + private boolean isHighContentionRoom(Long roomId) { + // Check booking frequency, room type, current load + return bookingMetrics.getRecentBookingAttempts(roomId) > CONTENTION_THRESHOLD; + } +} +``` + +### **Dynamic Strategy Selection:** +```java +@Component +public class LockingStrategy { + + public LockType determineLockType(Long roomId, LocalDateTime bookingTime) { + // Peak hours logic + if (isPeakBookingTime(bookingTime)) { + return LockType.PESSIMISTIC; + } + + // Popular room logic + if (isPopularRoom(roomId)) { + return LockType.PESSIMISTIC; + } + + // System load logic + if (getCurrentSystemLoad() > 0.8) { + return LockType.OPTIMISTIC; // Prefer throughput + } + + return LockType.OPTIMISTIC; // Default + } +} +``` + +## 4. Database Constraints Approach + +### **Alternative Strategy:** +Use database unique constraints to prevent double booking: + +```sql +-- Add unique constraint on room + date range +ALTER TABLE bookings ADD CONSTRAINT unique_room_dates +UNIQUE (room_id, check_in_date, check_out_date); + +-- Or use a status-based approach +ALTER TABLE rooms ADD CONSTRAINT check_available +CHECK (available IN (true, false)); + +-- Prevent overlapping bookings +CREATE UNIQUE INDEX idx_room_booking_period +ON bookings (room_id, check_in_date, check_out_date) +WHERE status = 'CONFIRMED'; +``` + +```java +@Service +public class HotelService { + + public Booking createBookingWithConstraints(Long roomId, ...) { + try { + // Let database handle concurrency + Booking booking = new Booking(room, ...); + return bookingRepository.save(booking); + } catch (DataIntegrityViolationException e) { + if (e.getMessage().contains("unique_room_dates")) { + throw new RoomAlreadyBookedException("Room is already booked for these dates"); + } + throw e; + } + } +} +``` + +### **Benefits:** +- Database-level consistency +- No application-level locking complexity +- Works across multiple application instances +- High performance + +## 5. Performance Comparison + +| Approach | Throughput | Consistency | Complexity | Best Use Case | +|----------|------------|-------------|------------|---------------| +| Optimistic | High | Eventually Strong | Low | Low contention scenarios | +| Pessimistic | Low-Medium | Strong | Medium | High contention scenarios | +| Hybrid | Medium-High | Strong | High | Mixed workload patterns | +| DB Constraints | High | Strong | Low | Simple booking rules | + +## 6. Implementation Considerations + +### **A. Retry Configuration:** +```java +@Configuration +@EnableRetry +public class RetryConfig { + + @Bean + public RetryTemplate retryTemplate() { + RetryTemplate template = new RetryTemplate(); + + FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); + backOffPolicy.setBackOffPeriod(100); // 100ms between retries + template.setBackOffPolicy(backOffPolicy); + + SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(); + retryPolicy.setMaxAttempts(3); + template.setRetryPolicy(retryPolicy); + + return template; + } +} +``` + +### **B. Lock Timeout Configuration:** +```properties +# application.properties +spring.jpa.properties.javax.persistence.lock.timeout=5000 +spring.jpa.properties.hibernate.dialect.lock_timeout=5000 +``` + +### **C. Exception Handling:** +```java +@ControllerAdvice +public class BookingExceptionHandler { + + @ExceptionHandler(OptimisticLockException.class) + public ResponseEntity handleOptimisticLock(OptimisticLockException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new ErrorResponse("Room was booked by another user. Please try again.")); + } + + @ExceptionHandler(PessimisticLockException.class) + public ResponseEntity handlePessimisticLock(PessimisticLockException e) { + return ResponseEntity.status(HttpStatus.LOCKED) + .body(new ErrorResponse("Room is temporarily locked. Please try again shortly.")); + } +} +``` + +## 7. Monitoring & Metrics + +```java +@Component +public class BookingMetrics { + + private final MeterRegistry meterRegistry; + private final Map roomContentionMap = new ConcurrentHashMap<>(); + + public void recordOptimisticLockRetry(String outcome) { + meterRegistry.counter("booking.optimistic.retry", "outcome", outcome).increment(); + } + + public void recordPessimisticLockWait(Duration waitTime) { + meterRegistry.timer("booking.pessimistic.wait").record(waitTime); + } + + public void recordBookingAttempt(Long roomId) { + roomContentionMap.computeIfAbsent(roomId, k -> new AtomicInteger(0)).incrementAndGet(); + meterRegistry.counter("booking.attempts", "roomId", roomId.toString()).increment(); + } + + public int getRecentBookingAttempts(Long roomId) { + return roomContentionMap.getOrDefault(roomId, new AtomicInteger(0)).get(); + } +} +``` + +### **Key Metrics to Track:** +- Optimistic lock retry rates +- Pessimistic lock wait times +- Booking success/failure rates +- Room contention levels +- Database deadlock occurrences + +## 8. Testing Strategies + +### **A. Concurrency Test:** +```java +@Test +public void testConcurrentBookings() throws InterruptedException { + Long roomId = 1L; + int numberOfThreads = 10; + CountDownLatch latch = new CountDownLatch(numberOfThreads); + ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < numberOfThreads; i++) { + Future future = executor.submit(() -> { + try { + latch.countDown(); + latch.await(); // All threads start simultaneously + return hotelService.createBooking(roomId, "Guest" + Thread.currentThread().getId(), + "test@email.com", LocalDate.now(), LocalDate.now().plusDays(1)); + } catch (Exception e) { + return null; + } + }); + futures.add(future); + } + + // Only one booking should succeed + long successfulBookings = futures.stream() + .map(f -> { + try { return f.get(); } catch (Exception e) { return null; } + }) + .filter(Objects::nonNull) + .count(); + + assertEquals(1, successfulBookings); +} +``` + +### **B. Load Testing:** +```java +@Test +public void loadTestBookingSystem() { + // Simulate high load with JMeter or similar tool + // Measure throughput, response times, error rates + // Validate data consistency after test completion +} +``` + +## 9. Recommendations + +### **For Production Implementation:** + +1. **Start with Hybrid Approach**: Use optimistic locking as default with pessimistic for high-contention scenarios + +2. **Implement Comprehensive Monitoring**: Track lock contention, retry rates, and performance metrics + +3. **Use Database Constraints**: Add unique constraints as a safety net + +4. **Configure Appropriate Timeouts**: Set reasonable lock timeouts to prevent indefinite blocking + +5. **Handle Exceptions Gracefully**: Provide meaningful error messages to users + +6. **Test Thoroughly**: Implement comprehensive concurrency and load tests + +7. **Consider Business Rules**: Factor in cancellation policies, overbooking strategies, and booking windows + +### **Migration Strategy:** +1. Phase 1: Implement optimistic locking with version fields +2. Phase 2: Add monitoring and metrics collection +3. Phase 3: Implement pessimistic locking for high-contention scenarios +4. Phase 4: Deploy hybrid strategy with dynamic selection +5. Phase 5: Fine-tune based on production metrics + +The **hybrid approach** with **database constraints** as a safety net provides the best balance of performance, consistency, and reliability for a production hotel booking system handling multiple concurrent users and application instances. \ No newline at end of file diff --git a/HotelApp/scripts/test-pessimistic-lock.sh b/HotelApp/scripts/test-pessimistic-lock.sh new file mode 100644 index 000000000..46d6bdddc --- /dev/null +++ b/HotelApp/scripts/test-pessimistic-lock.sh @@ -0,0 +1,218 @@ +#!/bin/bash + +# E2E Test Script for Pessimistic Lock with Multiple Instances +# This script tests the pessimistic locking mechanism by: +# 1. Starting multiple app instances +# 2. Sending concurrent booking requests +# 3. Verifying only one booking succeeds + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}๐Ÿš€ Starting Pessimistic Lock E2E Test${NC}" +echo "========================================" + +# Configuration +ROOM_ID=1 +GUEST_BASE_NAME="TestGuest" +EMAIL_BASE="test" +CHECK_IN="2024-12-25" +CHECK_OUT="2024-12-27" +CONCURRENT_REQUESTS=5 + +# Function to start app instance +start_instance() { + local port=$1 + local instance_id=$2 + + echo -e "${YELLOW}๐Ÿ“ก Starting instance ${instance_id} on port ${port}${NC}" + + INSTANCE_ID="${instance_id}" PORT="${port}" nohup mvn spring-boot:run \ + > logs/instance-${instance_id}.log 2>&1 & + + local pid=$! + echo "${pid}" > "logs/instance-${instance_id}.pid" + + # Wait for instance to start + echo "โณ Waiting for instance ${instance_id} to start..." + for i in {1..30}; do + if curl -s "http://localhost:${port}/actuator/health" > /dev/null 2>&1; then + echo -e "${GREEN}โœ… Instance ${instance_id} started successfully${NC}" + return 0 + fi + sleep 2 + done + + echo -e "${RED}โŒ Instance ${instance_id} failed to start${NC}" + return 1 +} + +# Function to stop instance +stop_instance() { + local instance_id=$1 + local pid_file="logs/instance-${instance_id}.pid" + + if [ -f "${pid_file}" ]; then + local pid=$(cat "${pid_file}") + echo -e "${YELLOW}๐Ÿ›‘ Stopping instance ${instance_id} (PID: ${pid})${NC}" + kill "${pid}" 2>/dev/null || true + rm -f "${pid_file}" + fi +} + +# Function to send booking request +send_booking_request() { + local port=$1 + local guest_name=$2 + local email=$3 + local request_id=$4 + + local response=$(curl -s -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d "{ + \"roomId\": ${ROOM_ID}, + \"guestName\": \"${guest_name}\", + \"guestEmail\": \"${email}@test.com\", + \"checkInDate\": \"${CHECK_IN}\", + \"checkOutDate\": \"${CHECK_OUT}\" + }" \ + "http://localhost:${port}/api/hotel/bookings" 2>/dev/null) + + local http_code="${response: -3}" + local response_body="${response%???}" + + echo "Request ${request_id}: HTTP ${http_code} - ${response_body}" + + if [ "${http_code}" = "200" ]; then + return 0 + else + return 1 + fi +} + +# Function to cleanup +cleanup() { + echo -e "${YELLOW}๐Ÿงน Cleaning up...${NC}" + stop_instance "A" + stop_instance "B" + stop_instance "C" + + # Kill any remaining processes + pkill -f "spring-boot:run" 2>/dev/null || true + + echo -e "${GREEN}โœ… Cleanup completed${NC}" +} + +# Trap cleanup on exit +trap cleanup EXIT + +# Create logs directory +mkdir -p logs + +# Start MySQL if using Docker Compose +echo -e "${BLUE}๐Ÿฌ Starting MySQL...${NC}" +docker-compose up -d mysql +sleep 10 + +echo -e "${BLUE}๐Ÿƒโ€โ™‚๏ธ Starting multiple app instances...${NC}" + +# Start 3 instances +start_instance 8081 "A" +start_instance 8082 "B" +start_instance 8083 "C" + +echo -e "${BLUE}โฐ Waiting for all instances to be ready...${NC}" +sleep 5 + +echo -e "${BLUE}๐ŸŽฏ Starting concurrent booking test...${NC}" +echo "Target: Room ${ROOM_ID}, ${CONCURRENT_REQUESTS} concurrent requests" + +# Initialize room data (ensure room exists and is available) +echo -e "${YELLOW}๐Ÿ  Ensuring test room exists and is available...${NC}" +curl -s "http://localhost:8081/api/hotel/rooms" > /dev/null + +echo -e "${BLUE}๐Ÿš€ Sending ${CONCURRENT_REQUESTS} concurrent booking requests...${NC}" + +# Send concurrent requests to different instances +declare -a pids=() +declare -a results=() + +for i in $(seq 1 ${CONCURRENT_REQUESTS}); do + local port=$((8080 + (i % 3) + 1)) # Distribute across instances 8081, 8082, 8083 + local guest_name="${GUEST_BASE_NAME}${i}" + local email="${EMAIL_BASE}${i}" + + echo "๐Ÿ“ค Sending request ${i} to port ${port}..." + + # Send request in background + ( + send_booking_request "${port}" "${guest_name}" "${email}" "${i}" + echo $? > "logs/result-${i}.tmp" + ) & + + pids+=($!) +done + +echo -e "${YELLOW}โณ Waiting for all requests to complete...${NC}" + +# Wait for all requests to complete +for pid in "${pids[@]}"; do + wait "${pid}" +done + +sleep 2 + +# Analyze results +echo -e "${BLUE}๐Ÿ“Š Analyzing results...${NC}" +echo "==========================" + +successful_bookings=0 +failed_bookings=0 + +for i in $(seq 1 ${CONCURRENT_REQUESTS}); do + if [ -f "logs/result-${i}.tmp" ]; then + result=$(cat "logs/result-${i}.tmp") + if [ "${result}" = "0" ]; then + successful_bookings=$((successful_bookings + 1)) + echo -e "Request ${i}: ${GREEN}SUCCESS${NC}" + else + failed_bookings=$((failed_bookings + 1)) + echo -e "Request ${i}: ${RED}FAILED${NC}" + fi + rm -f "logs/result-${i}.tmp" + else + failed_bookings=$((failed_bookings + 1)) + echo -e "Request ${i}: ${RED}NO RESULT${NC}" + fi +done + +echo "==========================" +echo -e "Successful bookings: ${GREEN}${successful_bookings}${NC}" +echo -e "Failed bookings: ${RED}${failed_bookings}${NC}" + +# Verify pessimistic lock worked correctly +echo -e "${BLUE}๐Ÿ” Verifying pessimistic lock behavior...${NC}" + +if [ "${successful_bookings}" = "1" ] && [ "${failed_bookings}" = "$((CONCURRENT_REQUESTS - 1))" ]; then + echo -e "${GREEN}โœ… PESSIMISTIC LOCK TEST PASSED!${NC}" + echo -e "${GREEN} Only 1 booking succeeded, ${failed_bookings} failed as expected${NC}" + exit_code=0 +else + echo -e "${RED}โŒ PESSIMISTIC LOCK TEST FAILED!${NC}" + echo -e "${RED} Expected: 1 success, $((CONCURRENT_REQUESTS - 1)) failures${NC}" + echo -e "${RED} Actual: ${successful_bookings} success, ${failed_bookings} failures${NC}" + exit_code=1 +fi + +echo -e "${BLUE}๐Ÿ“‹ Check application logs for detailed lock behavior:${NC}" +echo " - logs/instance-A.log" +echo " - logs/instance-B.log" +echo " - logs/instance-C.log" + +exit ${exit_code} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/repository/RoomRepository.java b/HotelApp/src/main/java/com/yen/HotelApp/repository/RoomRepository.java index 005896004..13579f6b5 100644 --- a/HotelApp/src/main/java/com/yen/HotelApp/repository/RoomRepository.java +++ b/HotelApp/src/main/java/com/yen/HotelApp/repository/RoomRepository.java @@ -2,12 +2,25 @@ import com.yen.HotelApp.entity.Room; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; + +import jakarta.persistence.LockModeType; import java.util.List; +import java.util.Optional; @Repository public interface RoomRepository extends JpaRepository { List findByAvailable(Boolean available); List findByRoomType(String roomType); List findByAvailableAndRoomType(Boolean available, String roomType); + + /** + * PESSIMISTIC LOCK: JPA approach + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM Room r WHERE r.id = :id") + Optional findByIdWithLock(@Param("id") Long id); } \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/service/HotelService.java b/HotelApp/src/main/java/com/yen/HotelApp/service/HotelService.java index 566ddfcb4..c7aa59293 100644 --- a/HotelApp/src/main/java/com/yen/HotelApp/service/HotelService.java +++ b/HotelApp/src/main/java/com/yen/HotelApp/service/HotelService.java @@ -4,11 +4,15 @@ import com.yen.HotelApp.entity.Room; import com.yen.HotelApp.repository.BookingRepository; import com.yen.HotelApp.repository.RoomRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import jakarta.transaction.Transactional; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; @@ -17,11 +21,19 @@ @Transactional public class HotelService { + private static final Logger logger = LoggerFactory.getLogger(HotelService.class); + @Autowired private RoomRepository roomRepository; @Autowired private BookingRepository bookingRepository; + + @Value("${server.port:8080}") + private String serverPort; + + @Value("${spring.application.instance-id:UNKNOWN}") + private String instanceId; public List getAllRooms() { return roomRepository.findAll(); @@ -41,37 +53,88 @@ public Optional getRoomById(Long id) { public Booking createBooking(Long roomId, String guestName, String guestEmail, LocalDate checkInDate, LocalDate checkOutDate) { - - Optional roomOpt = roomRepository.findById(roomId); - if (!roomOpt.isPresent()) { - throw new RuntimeException("Room not found with id: " + roomId); - } - - Room room = roomOpt.get(); - if (!room.getAvailable()) { - throw new RuntimeException("Room is not available for booking"); - } - - if (checkInDate.isAfter(checkOutDate)) { - throw new RuntimeException("Check-in date must be before check-out date"); - } - if (checkInDate.isBefore(LocalDate.now())) { - throw new RuntimeException("Check-in date cannot be in the past"); - } - - long nights = ChronoUnit.DAYS.between(checkInDate, checkOutDate); - if (nights <= 0) { - throw new RuntimeException("Booking must be for at least one night"); + String requestId = generateRequestId(); + LocalDateTime startTime = LocalDateTime.now(); + + logger.info("๐Ÿ” [{}] Instance:{} Port:{} - BOOKING REQUEST STARTED - Room:{} Guest:{} Thread:{}", + requestId, instanceId, serverPort, roomId, guestName, Thread.currentThread().getName()); + + try { + logger.info("๐Ÿ”’ [{}] Attempting to acquire PESSIMISTIC LOCK on Room:{}", requestId, roomId); + + /** + * PESSIMISTIC LOCK - This will block until lock is acquired + */ + Optional roomOpt = roomRepository.findByIdWithLock(roomId); + LocalDateTime lockAcquiredTime = LocalDateTime.now(); + long waitTimeMs = ChronoUnit.MILLIS.between(startTime, lockAcquiredTime); + + logger.info("โœ… [{}] PESSIMISTIC LOCK ACQUIRED on Room:{} - Wait time: {}ms", + requestId, roomId, waitTimeMs); + + if (!roomOpt.isPresent()) { + logger.error("โŒ [{}] Room not found with id: {}", requestId, roomId); + throw new RuntimeException("Room not found with id: " + roomId); + } + + Room room = roomOpt.get(); + logger.info("๐Ÿ  [{}] Room:{} current availability: {}", requestId, roomId, room.getAvailable()); + + if (!room.getAvailable()) { + logger.warn("โš ๏ธ [{}] Room:{} is not available for booking", requestId, roomId); + throw new RuntimeException("Room is not available for booking"); + } + + logger.info("๐Ÿ“‹ [{}] Validating booking dates - CheckIn:{} CheckOut:{}", + requestId, checkInDate, checkOutDate); + + if (checkInDate.isAfter(checkOutDate)) { + logger.error("โŒ [{}] Invalid dates: Check-in {} is after check-out {}", + requestId, checkInDate, checkOutDate); + throw new RuntimeException("Check-in date must be before check-out date"); + } + + if (checkInDate.isBefore(LocalDate.now())) { + logger.error("โŒ [{}] Invalid check-in date {} is in the past", requestId, checkInDate); + throw new RuntimeException("Check-in date cannot be in the past"); + } + + long nights = ChronoUnit.DAYS.between(checkInDate, checkOutDate); + if (nights <= 0) { + logger.error("โŒ [{}] Invalid booking duration: {} nights", requestId, nights); + throw new RuntimeException("Booking must be for at least one night"); + } + + Double totalPrice = room.getPrice() * nights; + logger.info("๐Ÿ’ฐ [{}] Booking calculation: {}nights ร— ${} = ${}", + requestId, nights, room.getPrice(), totalPrice); + + logger.info("๐Ÿ”„ [{}] Updating room availability to FALSE and creating booking", requestId); + room.setAvailable(false); + roomRepository.save(room); + + Booking booking = new Booking(room, guestName, guestEmail, checkInDate, checkOutDate, totalPrice); + booking = bookingRepository.save(booking); + + LocalDateTime endTime = LocalDateTime.now(); + long totalTimeMs = ChronoUnit.MILLIS.between(startTime, endTime); + + logger.info("๐ŸŽ‰ [{}] BOOKING COMPLETED SUCCESSFULLY - BookingId:{} TotalTime:{}ms", + requestId, booking.getId(), totalTimeMs); + logger.info("๐Ÿ”“ [{}] PESSIMISTIC LOCK RELEASED on Room:{}", requestId, roomId); + + return booking; + + } catch (Exception e) { + LocalDateTime endTime = LocalDateTime.now(); + long totalTimeMs = ChronoUnit.MILLIS.between(startTime, endTime); + + logger.error("๐Ÿ’ฅ [{}] BOOKING FAILED - Error:{} TotalTime:{}ms", + requestId, e.getMessage(), totalTimeMs); + logger.info("๐Ÿ”“ [{}] PESSIMISTIC LOCK RELEASED on Room:{} (due to error)", requestId, roomId); + throw e; } - - Double totalPrice = room.getPrice() * nights; - - room.setAvailable(false); - roomRepository.save(room); - - Booking booking = new Booking(room, guestName, guestEmail, checkInDate, checkOutDate, totalPrice); - return bookingRepository.save(booking); } public List getAllBookings() { @@ -101,4 +164,8 @@ public void cancelBooking(Long bookingId) { bookingRepository.save(booking); } + + private String generateRequestId() { + return "REQ-" + System.currentTimeMillis() + "-" + Thread.currentThread().getName().hashCode(); + } } \ No newline at end of file diff --git a/HotelApp/src/main/resources/application-docker.properties b/HotelApp/src/main/resources/application-docker.properties index ebe502812..e39434a1f 100644 --- a/HotelApp/src/main/resources/application-docker.properties +++ b/HotelApp/src/main/resources/application-docker.properties @@ -12,4 +12,8 @@ spring.jpa.show-sql=false spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=60000 -spring.datasource.hikari.idle-timeout=300000 \ No newline at end of file +spring.datasource.hikari.idle-timeout=300000 + +# Pessimistic Lock Timeout (5 seconds) +spring.jpa.properties.jakarta.persistence.lock.timeout=5000 +spring.jpa.properties.hibernate.dialect.lock_timeout=5000 \ No newline at end of file diff --git a/HotelApp/src/main/resources/application.properties b/HotelApp/src/main/resources/application.properties index 92e20b9be..150fc054d 100644 --- a/HotelApp/src/main/resources/application.properties +++ b/HotelApp/src/main/resources/application.properties @@ -16,3 +16,15 @@ spring.jpa.properties.hibernate.format_sql=true spring.datasource.hikari.maximum-pool-size=10 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=60000 + +# Pessimistic Lock Timeout (5 seconds) +spring.jpa.properties.jakarta.persistence.lock.timeout=5000 +spring.jpa.properties.hibernate.dialect.lock_timeout=5000 + +# Application Instance Configuration +spring.application.instance-id=${INSTANCE_ID:MAIN} +server.port=${PORT:8080} + +# Enhanced Logging for Pessimistic Lock Testing +logging.level.com.yen.HotelApp.service.HotelService=INFO +logging.pattern.console=%d{HH:mm:ss.SSS} [%thread] %-5level [Instance:${spring.application.instance-id}] %logger{36} - %msg%n diff --git a/HotelApp/src/test/java/com/yen/HotelApp/service/PessimisticLockingTest.java b/HotelApp/src/test/java/com/yen/HotelApp/service/PessimisticLockingTest.java new file mode 100644 index 000000000..691f69e70 --- /dev/null +++ b/HotelApp/src/test/java/com/yen/HotelApp/service/PessimisticLockingTest.java @@ -0,0 +1,123 @@ +package com.yen.HotelApp.service; + +import com.yen.HotelApp.entity.Booking; +import com.yen.HotelApp.entity.Room; +import com.yen.HotelApp.repository.RoomRepository; +import com.yen.HotelApp.repository.BookingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +public class PessimisticLockingTest { + + @Autowired + private HotelService hotelService; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private BookingRepository bookingRepository; + + private Room testRoom; + + @BeforeEach + public void setUp() { + // Clear existing data + bookingRepository.deleteAll(); + roomRepository.deleteAll(); + + // Create a test room + testRoom = new Room("TEST-001", "Single", 100.0, "Test room for locking"); + testRoom = roomRepository.save(testRoom); + } + + @Test + public void testPessimisticLockPreventsDoubleBooking() throws InterruptedException { + int numberOfThreads = 5; + ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch completeLatch = new CountDownLatch(numberOfThreads); + + AtomicInteger successfulBookings = new AtomicInteger(0); + AtomicInteger failedBookings = new AtomicInteger(0); + + // Submit multiple booking requests simultaneously + for (int i = 0; i < numberOfThreads; i++) { + final int threadId = i; + executor.submit(() -> { + try { + // Wait for all threads to be ready + startLatch.await(); + + // Attempt to book the same room + Booking booking = hotelService.createBooking( + testRoom.getId(), + "Guest-" + threadId, + "guest" + threadId + "@test.com", + LocalDate.now().plusDays(1), + LocalDate.now().plusDays(2) + ); + + if (booking != null) { + successfulBookings.incrementAndGet(); + System.out.println("Thread " + threadId + ": Booking successful - ID: " + booking.getId()); + } + + } catch (Exception e) { + failedBookings.incrementAndGet(); + System.out.println("Thread " + threadId + ": Booking failed - " + e.getMessage()); + } finally { + completeLatch.countDown(); + } + }); + } + + // Start all threads simultaneously + startLatch.countDown(); + + // Wait for all threads to complete (with timeout) + boolean completed = completeLatch.await(30, TimeUnit.SECONDS); + assertTrue(completed, "All threads should complete within timeout"); + + executor.shutdown(); + + // Verify results + System.out.println("Successful bookings: " + successfulBookings.get()); + System.out.println("Failed bookings: " + failedBookings.get()); + + // Only one booking should succeed with pessimistic locking + assertEquals(1, successfulBookings.get(), "Only one booking should succeed"); + assertEquals(numberOfThreads - 1, failedBookings.get(), "All other bookings should fail"); + + // Verify room is no longer available + Room updatedRoom = roomRepository.findById(testRoom.getId()).orElse(null); + assertNotNull(updatedRoom); + assertFalse(updatedRoom.getAvailable(), "Room should be marked as unavailable"); + + // Verify only one booking exists in database + long bookingCount = bookingRepository.count(); + assertEquals(1, bookingCount, "Only one booking should exist in database"); + } + + @Test + @Transactional + public void testLockTimeoutConfiguration() { + // This test verifies that lock timeout is configured + // In a real scenario, this would timeout if another transaction holds the lock + Room room = roomRepository.findByIdWithLock(testRoom.getId()).orElse(null); + assertNotNull(room, "Room should be found with lock"); + assertEquals(testRoom.getId(), room.getId(), "Correct room should be returned"); + } +} \ No newline at end of file diff --git a/HotelApp/src/test/resources/application-test.properties b/HotelApp/src/test/resources/application-test.properties new file mode 100644 index 000000000..de2dad254 --- /dev/null +++ b/HotelApp/src/test/resources/application-test.properties @@ -0,0 +1,17 @@ +# Test-specific MySQL Configuration +spring.datasource.url=jdbc:mysql://localhost:3306/hoteldb_test?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC +spring.datasource.username=root +spring.datasource.password= +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA Configuration for Testing +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# Pessimistic Lock Timeout (shorter for tests) +spring.jpa.properties.jakarta.persistence.lock.timeout=2000 +spring.jpa.properties.hibernate.dialect.lock_timeout=2000 + +# Disable data initialization for tests +spring.sql.init.mode=never \ No newline at end of file