From 56797e0e965917b8c62ad452ea9d1db41641f303 Mon Sep 17 00:00:00 2001 From: jim Date: Sun, 7 Sep 2025 17:47:51 +0800 Subject: [PATCH 1/3] add doc --- HotelApp/2PC_IMPLEMENTATION_PLAN.md | 229 ++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 HotelApp/2PC_IMPLEMENTATION_PLAN.md diff --git a/HotelApp/2PC_IMPLEMENTATION_PLAN.md b/HotelApp/2PC_IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..eb96567d6 --- /dev/null +++ b/HotelApp/2PC_IMPLEMENTATION_PLAN.md @@ -0,0 +1,229 @@ +# Two-Phase Commit (2PC) Implementation Plan for Hotel Booking App + +## Overview +This document outlines the implementation strategy for Two-Phase Commit protocol in the hotel booking application to demonstrate distributed transaction management for POC purposes. + +## Current Architecture Analysis +- Single Spring Boot application with MySQL database +- Simple booking flow: Room availability check → Create booking → Update room status +- Monolithic architecture with direct database access + +## 2PC Architecture Design + +### 1. System Components Decomposition +To demonstrate 2PC, we need to split the monolithic booking process into multiple distributed components: + +#### Resource Managers (Participants) +1. **Room Service** - Manages room inventory and availability +2. **Booking Service** - Handles booking records and customer data +3. **Payment Service** - Processes payment transactions (mock implementation) +4. **Notification Service** - Sends booking confirmations (mock implementation) + +#### Transaction Manager (Coordinator) +- **Booking Coordinator Service** - Orchestrates the 2PC protocol across all participants + +### 2. Implementation Steps + +#### Phase 1: Infrastructure Setup +1. **Create Service Interfaces** + - Define `TransactionParticipant` interface with prepare/commit/rollback methods + - Create `TransactionCoordinator` interface for managing distributed transactions + - Implement `TransactionContext` to track transaction state + +2. **Database Schema Changes** + - Add transaction log tables for each participant + - Add prepared transaction state tracking + - Implement transaction timeout mechanisms + +#### Phase 2: Participant Services Implementation +1. **Room Service Participant** + - Implement room reservation in prepared state + - Handle commit (finalize reservation) and rollback (release reservation) + - Add pessimistic locking for room availability during prepare phase + +2. **Booking Service Participant** + - Create booking record in prepared state + - Handle commit (activate booking) and rollback (delete booking) + - Maintain booking state transitions + +3. **Payment Service Participant** + - Mock payment authorization in prepare phase + - Handle commit (capture payment) and rollback (void authorization) + - Simulate payment gateway interactions + +4. **Notification Service Participant** + - Queue notification messages in prepare phase + - Handle commit (send notifications) and rollback (cancel notifications) + - Mock email/SMS delivery + +#### Phase 3: Transaction Coordinator Implementation +1. **Coordinator Logic** + - Implement 2PC protocol state machine + - Handle participant registration and communication + - Manage transaction timeouts and recovery + - Implement logging for transaction audit trail + +2. **Communication Layer** + - REST API endpoints for participant communication + - Asynchronous message handling for prepare/commit/rollback + - Error handling and retry mechanisms + +#### Phase 4: Integration and Testing +1. **End-to-End Booking Flow** + - Replace direct database calls with 2PC transaction calls + - Update booking controller to use transaction coordinator + - Handle distributed transaction exceptions + +2. **Testing Framework** + - Unit tests for each participant + - Integration tests for 2PC protocol + - Failure scenario testing (network partitions, timeouts) + +## Detailed Implementation Architecture + +### Transaction Flow Diagram +``` +Client Request + ↓ +Booking Controller + ↓ +Transaction Coordinator + ↓ +Phase 1: PREPARE +├── Room Service → PREPARED/ABORTED +├── Booking Service → PREPARED/ABORTED +├── Payment Service → PREPARED/ABORTED +└── Notification Service → PREPARED/ABORTED + ↓ +Phase 2: COMMIT/ROLLBACK +├── Room Service → COMMITTED/ROLLED_BACK +├── Booking Service → COMMITTED/ROLLED_BACK +├── Payment Service → COMMITTED/ROLLED_BACK +└── Notification Service → COMMITTED/ROLLED_BACK + ↓ +Response to Client +``` + +### Data Structures + +#### Transaction Context +```java +class TransactionContext { + String transactionId; + TransactionState state; + List participants; + LocalDateTime startTime; + LocalDateTime timeoutTime; + Map participantStates; +} +``` + +#### Transaction Log Entry +```java +class TransactionLogEntry { + String transactionId; + String participantId; + TransactionPhase phase; + ParticipantState state; + LocalDateTime timestamp; + String payload; +} +``` + +## Technical Considerations + +### 1. Consistency and Durability +- Each participant must log prepare decisions before responding +- Coordinator must log commit/rollback decisions before proceeding +- Use write-ahead logging (WAL) for transaction recovery + +### 2. Timeout Management +- Set appropriate timeouts for prepare and commit phases +- Implement participant heartbeat monitoring +- Handle coordinator failure and recovery scenarios + +### 3. Error Handling +- Network partition tolerance +- Participant failure during prepare/commit phases +- Coordinator failure and recovery mechanisms +- Handling of uncertain transaction outcomes + +### 4. Performance Optimization +- Minimize blocking time during prepare phase +- Implement parallel participant communication +- Add transaction pooling and connection management +- Consider read-only transaction optimizations + +## Mock Services Implementation Strategy + +### Payment Service Mock +- Simulate 10% failure rate for testing +- Add artificial delays to simulate network latency +- Implement idempotent operations for retry scenarios + +### Notification Service Mock +- Queue-based message handling +- Simulate delivery confirmations +- Handle rollback scenarios (message cancellation) + +## Testing Strategy + +### 1. Happy Path Testing +- Successful booking with all participants committing +- Verify data consistency across all services +- Performance benchmarking against direct database approach + +### 2. Failure Scenario Testing +- Participant failure during prepare phase +- Participant failure during commit phase +- Coordinator failure and recovery +- Network partition scenarios +- Timeout handling + +### 3. Concurrency Testing +- Multiple simultaneous booking requests +- Race condition handling +- Deadlock prevention and detection + +## Migration Strategy + +### Phase 1: Parallel Implementation +- Keep existing booking flow intact +- Implement 2PC as alternative booking path +- Add feature flag to switch between approaches + +### Phase 2: Gradual Rollout +- Route percentage of traffic through 2PC +- Monitor performance and error rates +- Gradual increase in 2PC traffic + +### Phase 3: Full Migration +- Switch all booking traffic to 2PC +- Remove legacy booking code +- Optimize based on production metrics + +## Expected Outcomes + +### Benefits Demonstration +- Guaranteed consistency across distributed components +- Atomic booking operations spanning multiple services +- Clear transaction boundaries and rollback capabilities + +### Trade-offs Showcase +- Increased latency due to 2PC protocol overhead +- Higher complexity in error handling and recovery +- Resource overhead from transaction logging and coordination + +### Learning Objectives +- Understanding distributed transaction challenges +- Experience with consensus protocols +- Appreciation for simpler consistency models (like optimistic locking) + +## Implementation Timeline + +1. **Week 1**: Infrastructure and interfaces setup +2. **Week 2**: Participant services implementation +3. **Week 3**: Transaction coordinator development +4. **Week 4**: Integration, testing, and documentation + +This POC will provide hands-on experience with distributed transactions while highlighting why simpler approaches (like optimistic locking) are often preferred for single-database applications. \ No newline at end of file From 4695abce90b0cdb57e0daf66cf364a49904a1c92 Mon Sep 17 00:00:00 2001 From: jim Date: Sun, 7 Sep 2025 17:51:57 +0800 Subject: [PATCH 2/3] 2pc implement Phase 1: Infrastructure Setup --- .../HotelApp/entity/PreparedTransaction.java | 93 ++++++++++++ .../HotelApp/entity/TransactionLogEntry.java | 130 +++++++++++++++++ .../HotelApp/entity/TransactionRecord.java | 123 ++++++++++++++++ .../PreparedTransactionRepository.java | 31 ++++ .../repository/TransactionLogRepository.java | 29 ++++ .../TransactionRecordRepository.java | 32 ++++ .../transaction/ParticipantState.java | 10 ++ .../transaction/TransactionContext.java | 138 ++++++++++++++++++ .../transaction/TransactionCoordinator.java | 22 +++ .../transaction/TransactionParticipant.java | 16 ++ .../transaction/TransactionPhase.java | 7 + .../transaction/TransactionResult.java | 9 ++ .../transaction/TransactionState.java | 14 ++ .../V3__create_transaction_tables.sql | 55 +++++++ 14 files changed, 709 insertions(+) create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/entity/PreparedTransaction.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionLogEntry.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionRecord.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/repository/PreparedTransactionRepository.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionLogRepository.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionRecordRepository.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/ParticipantState.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionContext.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionCoordinator.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionParticipant.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionPhase.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionResult.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionState.java create mode 100644 HotelApp/src/main/resources/db/migration/V3__create_transaction_tables.sql diff --git a/HotelApp/src/main/java/com/yen/HotelApp/entity/PreparedTransaction.java b/HotelApp/src/main/java/com/yen/HotelApp/entity/PreparedTransaction.java new file mode 100644 index 000000000..0e706ebc7 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/entity/PreparedTransaction.java @@ -0,0 +1,93 @@ +package com.yen.HotelApp.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "prepared_transactions") +public class PreparedTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String transactionId; + + @Column(nullable = false) + private String participantId; + + @Column(columnDefinition = "TEXT") + private String preparedData; + + @Column(nullable = false) + private LocalDateTime preparedAt; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + public PreparedTransaction() { + this.preparedAt = LocalDateTime.now(); + } + + public PreparedTransaction(String transactionId, String participantId, + String preparedData, LocalDateTime expiresAt) { + this(); + this.transactionId = transactionId; + this.participantId = participantId; + this.preparedData = preparedData; + this.expiresAt = expiresAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTransactionId() { + return transactionId; + } + + public void setTransactionId(String transactionId) { + this.transactionId = transactionId; + } + + public String getParticipantId() { + return participantId; + } + + public void setParticipantId(String participantId) { + this.participantId = participantId; + } + + public String getPreparedData() { + return preparedData; + } + + public void setPreparedData(String preparedData) { + this.preparedData = preparedData; + } + + public LocalDateTime getPreparedAt() { + return preparedAt; + } + + public void setPreparedAt(LocalDateTime preparedAt) { + this.preparedAt = preparedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionLogEntry.java b/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionLogEntry.java new file mode 100644 index 000000000..03d821f4f --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionLogEntry.java @@ -0,0 +1,130 @@ +package com.yen.HotelApp.entity; + +import com.yen.HotelApp.transaction.ParticipantState; +import com.yen.HotelApp.transaction.TransactionPhase; +import com.yen.HotelApp.transaction.TransactionState; +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "transaction_log") +public class TransactionLogEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String transactionId; + + @Column(nullable = false) + private String participantId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransactionPhase phase; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ParticipantState participantState; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransactionState transactionState; + + @Column(nullable = false) + private LocalDateTime timestamp; + + @Column(columnDefinition = "TEXT") + private String payload; + + @Column + private String errorMessage; + + public TransactionLogEntry() { + this.timestamp = LocalDateTime.now(); + } + + public TransactionLogEntry(String transactionId, String participantId, + TransactionPhase phase, ParticipantState participantState, + TransactionState transactionState) { + this(); + this.transactionId = transactionId; + this.participantId = participantId; + this.phase = phase; + this.participantState = participantState; + this.transactionState = transactionState; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTransactionId() { + return transactionId; + } + + public void setTransactionId(String transactionId) { + this.transactionId = transactionId; + } + + public String getParticipantId() { + return participantId; + } + + public void setParticipantId(String participantId) { + this.participantId = participantId; + } + + public TransactionPhase getPhase() { + return phase; + } + + public void setPhase(TransactionPhase phase) { + this.phase = phase; + } + + public ParticipantState getParticipantState() { + return participantState; + } + + public void setParticipantState(ParticipantState participantState) { + this.participantState = participantState; + } + + public TransactionState getTransactionState() { + return transactionState; + } + + public void setTransactionState(TransactionState transactionState) { + this.transactionState = transactionState; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionRecord.java b/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionRecord.java new file mode 100644 index 000000000..652f58141 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/entity/TransactionRecord.java @@ -0,0 +1,123 @@ +package com.yen.HotelApp.entity; + +import com.yen.HotelApp.transaction.TransactionState; +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "transaction_records") +public class TransactionRecord { + + @Id + private String transactionId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TransactionState state; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column + private LocalDateTime endTime; + + @Column(nullable = false) + private LocalDateTime timeoutTime; + + @Column(nullable = false) + private String participantIds; + + @Column(columnDefinition = "TEXT") + private String transactionData; + + @Column + private String initiatorId; + + @Column + private String errorMessage; + + public TransactionRecord() {} + + public TransactionRecord(String transactionId, TransactionState state, + LocalDateTime startTime, LocalDateTime timeoutTime, + String participantIds, String transactionData) { + this.transactionId = transactionId; + this.state = state; + this.startTime = startTime; + this.timeoutTime = timeoutTime; + this.participantIds = participantIds; + this.transactionData = transactionData; + } + + public String getTransactionId() { + return transactionId; + } + + public void setTransactionId(String transactionId) { + this.transactionId = transactionId; + } + + public TransactionState getState() { + return state; + } + + public void setState(TransactionState state) { + this.state = state; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalDateTime endTime) { + this.endTime = endTime; + } + + public LocalDateTime getTimeoutTime() { + return timeoutTime; + } + + public void setTimeoutTime(LocalDateTime timeoutTime) { + this.timeoutTime = timeoutTime; + } + + public String getParticipantIds() { + return participantIds; + } + + public void setParticipantIds(String participantIds) { + this.participantIds = participantIds; + } + + public String getTransactionData() { + return transactionData; + } + + public void setTransactionData(String transactionData) { + this.transactionData = transactionData; + } + + public String getInitiatorId() { + return initiatorId; + } + + public void setInitiatorId(String initiatorId) { + this.initiatorId = initiatorId; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/repository/PreparedTransactionRepository.java b/HotelApp/src/main/java/com/yen/HotelApp/repository/PreparedTransactionRepository.java new file mode 100644 index 000000000..7c3d025d9 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/repository/PreparedTransactionRepository.java @@ -0,0 +1,31 @@ +package com.yen.HotelApp.repository; + +import com.yen.HotelApp.entity.PreparedTransaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface PreparedTransactionRepository extends JpaRepository { + + List findByTransactionId(String transactionId); + + Optional findByTransactionIdAndParticipantId(String transactionId, String participantId); + + List findByParticipantId(String participantId); + + @Query("SELECT p FROM PreparedTransaction p WHERE p.expiresAt < :now") + List findExpiredTransactions(@Param("now") LocalDateTime now); + + void deleteByTransactionId(String transactionId); + + void deleteByTransactionIdAndParticipantId(String transactionId, String participantId); + + @Query("SELECT COUNT(p) FROM PreparedTransaction p WHERE p.expiresAt > :now") + long countActivePreparedTransactions(@Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionLogRepository.java b/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionLogRepository.java new file mode 100644 index 000000000..8c58f65b1 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionLogRepository.java @@ -0,0 +1,29 @@ +package com.yen.HotelApp.repository; + +import com.yen.HotelApp.entity.TransactionLogEntry; +import com.yen.HotelApp.transaction.TransactionPhase; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface TransactionLogRepository extends JpaRepository { + + List findByTransactionIdOrderByTimestampAsc(String transactionId); + + List findByTransactionIdAndParticipantId(String transactionId, String participantId); + + List findByTransactionIdAndPhase(String transactionId, TransactionPhase phase); + + @Query("SELECT t FROM TransactionLogEntry t WHERE t.timestamp >= :since ORDER BY t.timestamp DESC") + List findRecentEntries(@Param("since") LocalDateTime since); + + @Query("SELECT DISTINCT t.transactionId FROM TransactionLogEntry t WHERE t.participantId = :participantId") + List findTransactionIdsByParticipant(@Param("participantId") String participantId); + + void deleteByTransactionId(String transactionId); +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionRecordRepository.java b/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionRecordRepository.java new file mode 100644 index 000000000..d967cd92c --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/repository/TransactionRecordRepository.java @@ -0,0 +1,32 @@ +package com.yen.HotelApp.repository; + +import com.yen.HotelApp.entity.TransactionRecord; +import com.yen.HotelApp.transaction.TransactionState; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface TransactionRecordRepository extends JpaRepository { + + List findByState(TransactionState state); + + List findByStateIn(List states); + + @Query("SELECT t FROM TransactionRecord t WHERE t.timeoutTime < :now AND t.state IN :activeStates") + List findExpiredTransactions(@Param("now") LocalDateTime now, + @Param("activeStates") List activeStates); + + @Query("SELECT t FROM TransactionRecord t WHERE t.endTime IS NOT NULL AND t.endTime < :cutoff") + List findCompletedTransactionsBefore(@Param("cutoff") LocalDateTime cutoff); + + @Query("SELECT t FROM TransactionRecord t WHERE t.participantIds LIKE %:participantId%") + List findTransactionsWithParticipant(@Param("participantId") String participantId); + + @Query("SELECT COUNT(t) FROM TransactionRecord t WHERE t.state IN :activeStates") + long countActiveTransactions(@Param("activeStates") List activeStates); +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/ParticipantState.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/ParticipantState.java new file mode 100644 index 000000000..6ebe20800 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/ParticipantState.java @@ -0,0 +1,10 @@ +package com.yen.HotelApp.transaction; + +public enum ParticipantState { + PREPARED, + ABORTED, + COMMITTED, + ROLLED_BACK, + TIMEOUT, + UNKNOWN +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionContext.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionContext.java new file mode 100644 index 000000000..eb95f46d2 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionContext.java @@ -0,0 +1,138 @@ +package com.yen.HotelApp.transaction; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class TransactionContext { + + private String transactionId; + private TransactionState state; + private List participantIds; + private LocalDateTime startTime; + private LocalDateTime timeoutTime; + private Map participantStates; + private Object transactionData; + private String initiatorId; + private int timeoutSeconds; + + public TransactionContext() { + this.participantStates = new ConcurrentHashMap<>(); + this.startTime = LocalDateTime.now(); + this.state = TransactionState.ACTIVE; + this.timeoutSeconds = 300; // 5 minutes default + } + + public TransactionContext(String transactionId, List participantIds, Object transactionData) { + this(); + this.transactionId = transactionId; + this.participantIds = participantIds; + this.transactionData = transactionData; + this.timeoutTime = this.startTime.plusSeconds(timeoutSeconds); + } + + public String getTransactionId() { + return transactionId; + } + + public void setTransactionId(String transactionId) { + this.transactionId = transactionId; + } + + public TransactionState getState() { + return state; + } + + public void setState(TransactionState state) { + this.state = state; + } + + public List getParticipantIds() { + return participantIds; + } + + public void setParticipantIds(List participantIds) { + this.participantIds = participantIds; + } + + public LocalDateTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalDateTime startTime) { + this.startTime = startTime; + } + + public LocalDateTime getTimeoutTime() { + return timeoutTime; + } + + public void setTimeoutTime(LocalDateTime timeoutTime) { + this.timeoutTime = timeoutTime; + } + + public Map getParticipantStates() { + return participantStates; + } + + public void setParticipantStates(Map participantStates) { + this.participantStates = participantStates; + } + + public Object getTransactionData() { + return transactionData; + } + + public void setTransactionData(Object transactionData) { + this.transactionData = transactionData; + } + + public String getInitiatorId() { + return initiatorId; + } + + public void setInitiatorId(String initiatorId) { + this.initiatorId = initiatorId; + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(int timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + if (this.startTime != null) { + this.timeoutTime = this.startTime.plusSeconds(timeoutSeconds); + } + } + + public void updateParticipantState(String participantId, ParticipantState state) { + this.participantStates.put(participantId, state); + } + + public ParticipantState getParticipantState(String participantId) { + return this.participantStates.get(participantId); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(timeoutTime); + } + + public boolean allParticipantsInState(ParticipantState state) { + if (participantIds == null || participantIds.isEmpty()) { + return false; + } + + for (String participantId : participantIds) { + if (!state.equals(participantStates.get(participantId))) { + return false; + } + } + return true; + } + + public boolean anyParticipantInState(ParticipantState state) { + return participantStates.values().contains(state); + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionCoordinator.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionCoordinator.java new file mode 100644 index 000000000..e441aa2f8 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionCoordinator.java @@ -0,0 +1,22 @@ +package com.yen.HotelApp.transaction; + +import java.util.List; + +public interface TransactionCoordinator { + + String beginTransaction(List participants, Object transactionData); + + TransactionResult executeTransaction(String transactionId); + + void abortTransaction(String transactionId); + + TransactionContext getTransactionContext(String transactionId); + + List getActiveTransactions(); + + void recoverTransaction(String transactionId); + + boolean isTransactionActive(String transactionId); + + void cleanupCompletedTransactions(); +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionParticipant.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionParticipant.java new file mode 100644 index 000000000..61d5b0087 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionParticipant.java @@ -0,0 +1,16 @@ +package com.yen.HotelApp.transaction; + +public interface TransactionParticipant { + + ParticipantState prepare(String transactionId, TransactionContext context); + + void commit(String transactionId); + + void rollback(String transactionId); + + String getParticipantId(); + + boolean canTimeout(); + + int getTimeoutSeconds(); +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionPhase.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionPhase.java new file mode 100644 index 000000000..3f5ed4a47 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionPhase.java @@ -0,0 +1,7 @@ +package com.yen.HotelApp.transaction; + +public enum TransactionPhase { + PREPARE, + COMMIT, + ROLLBACK +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionResult.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionResult.java new file mode 100644 index 000000000..be17051e6 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionResult.java @@ -0,0 +1,9 @@ +package com.yen.HotelApp.transaction; + +public enum TransactionResult { + COMMITTED, + ROLLED_BACK, + ABORTED, + TIMEOUT, + UNKNOWN +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionState.java b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionState.java new file mode 100644 index 000000000..811c3550a --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/transaction/TransactionState.java @@ -0,0 +1,14 @@ +package com.yen.HotelApp.transaction; + +public enum TransactionState { + ACTIVE, + PREPARING, + PREPARED, + COMMITTING, + COMMITTED, + ROLLING_BACK, + ROLLED_BACK, + ABORTED, + TIMEOUT, + UNKNOWN +} \ No newline at end of file diff --git a/HotelApp/src/main/resources/db/migration/V3__create_transaction_tables.sql b/HotelApp/src/main/resources/db/migration/V3__create_transaction_tables.sql new file mode 100644 index 000000000..a4af5af1b --- /dev/null +++ b/HotelApp/src/main/resources/db/migration/V3__create_transaction_tables.sql @@ -0,0 +1,55 @@ +-- Create transaction_records table for tracking overall transaction state +CREATE TABLE transaction_records ( + transaction_id VARCHAR(255) PRIMARY KEY, + state VARCHAR(50) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NULL, + timeout_time TIMESTAMP NOT NULL, + participant_ids TEXT NOT NULL, + transaction_data TEXT NULL, + initiator_id VARCHAR(255) NULL, + error_message TEXT NULL, + INDEX idx_transaction_state (state), + INDEX idx_transaction_start_time (start_time), + INDEX idx_transaction_timeout (timeout_time) +); + +-- Create transaction_log table for detailed transaction logging +CREATE TABLE transaction_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_id VARCHAR(255) NOT NULL, + participant_id VARCHAR(255) NOT NULL, + phase VARCHAR(50) NOT NULL, + participant_state VARCHAR(50) NOT NULL, + transaction_state VARCHAR(50) NOT NULL, + timestamp TIMESTAMP NOT NULL, + payload TEXT NULL, + error_message TEXT NULL, + INDEX idx_transaction_id (transaction_id), + INDEX idx_participant_id (participant_id), + INDEX idx_phase (phase), + INDEX idx_timestamp (timestamp), + FOREIGN KEY (transaction_id) REFERENCES transaction_records(transaction_id) +); + +-- Create prepared_transactions table for tracking participant prepared states +CREATE TABLE prepared_transactions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + transaction_id VARCHAR(255) NOT NULL, + participant_id VARCHAR(255) NOT NULL, + prepared_data TEXT NULL, + prepared_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + INDEX idx_prepared_transaction (transaction_id), + INDEX idx_prepared_participant (participant_id), + INDEX idx_prepared_expires (expires_at), + UNIQUE KEY unique_transaction_participant (transaction_id, participant_id), + FOREIGN KEY (transaction_id) REFERENCES transaction_records(transaction_id) +); + +-- Add transaction tracking to existing tables +ALTER TABLE rooms ADD COLUMN prepared_transaction_id VARCHAR(255) NULL; +ALTER TABLE rooms ADD INDEX idx_room_prepared_transaction (prepared_transaction_id); + +ALTER TABLE bookings ADD COLUMN prepared_transaction_id VARCHAR(255) NULL; +ALTER TABLE bookings ADD INDEX idx_booking_prepared_transaction (prepared_transaction_id); \ No newline at end of file From 274f7e1d2107fe9cb1949e78bc6f77836a56c77c Mon Sep 17 00:00:00 2001 From: jim Date: Sun, 7 Sep 2025 17:59:48 +0800 Subject: [PATCH 3/3] implement Phase 2: Participant Services Implementation --- .../com/yen/HotelApp/dto/BookingRequest.java | 94 +++++++ .../java/com/yen/HotelApp/entity/Booking.java | 13 +- .../java/com/yen/HotelApp/entity/Room.java | 11 + .../BookingServiceParticipant.java | 190 +++++++++++++ .../NotificationServiceParticipant.java | 261 ++++++++++++++++++ .../PaymentServiceParticipant.java | 205 ++++++++++++++ .../participant/RoomServiceParticipant.java | 182 ++++++++++++ 7 files changed, 955 insertions(+), 1 deletion(-) create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/dto/BookingRequest.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/service/participant/BookingServiceParticipant.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/service/participant/NotificationServiceParticipant.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/service/participant/PaymentServiceParticipant.java create mode 100644 HotelApp/src/main/java/com/yen/HotelApp/service/participant/RoomServiceParticipant.java diff --git a/HotelApp/src/main/java/com/yen/HotelApp/dto/BookingRequest.java b/HotelApp/src/main/java/com/yen/HotelApp/dto/BookingRequest.java new file mode 100644 index 000000000..f650c031d --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/dto/BookingRequest.java @@ -0,0 +1,94 @@ +package com.yen.HotelApp.dto; + +import java.time.LocalDate; + +public class BookingRequest { + + private Long roomId; + private String guestName; + private String guestEmail; + private LocalDate checkInDate; + private LocalDate checkOutDate; + private Double totalPrice; + private String paymentMethod; + private String paymentToken; + + public BookingRequest() {} + + public BookingRequest(Long roomId, String guestName, String guestEmail, + LocalDate checkInDate, LocalDate checkOutDate, + Double totalPrice, String paymentMethod, String paymentToken) { + this.roomId = roomId; + this.guestName = guestName; + this.guestEmail = guestEmail; + this.checkInDate = checkInDate; + this.checkOutDate = checkOutDate; + this.totalPrice = totalPrice; + this.paymentMethod = paymentMethod; + this.paymentToken = paymentToken; + } + + public Long getRoomId() { + return roomId; + } + + public void setRoomId(Long roomId) { + this.roomId = roomId; + } + + public String getGuestName() { + return guestName; + } + + public void setGuestName(String guestName) { + this.guestName = guestName; + } + + public String getGuestEmail() { + return guestEmail; + } + + public void setGuestEmail(String guestEmail) { + this.guestEmail = guestEmail; + } + + public LocalDate getCheckInDate() { + return checkInDate; + } + + public void setCheckInDate(LocalDate checkInDate) { + this.checkInDate = checkInDate; + } + + public LocalDate getCheckOutDate() { + return checkOutDate; + } + + public void setCheckOutDate(LocalDate checkOutDate) { + this.checkOutDate = checkOutDate; + } + + public Double getTotalPrice() { + return totalPrice; + } + + public void setTotalPrice(Double totalPrice) { + this.totalPrice = totalPrice; + } + + public String getPaymentMethod() { + return paymentMethod; + } + + public void setPaymentMethod(String paymentMethod) { + this.paymentMethod = paymentMethod; + } + + public String getPaymentToken() { + return paymentToken; + } + + public void setPaymentToken(String paymentToken) { + this.paymentToken = paymentToken; + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/entity/Booking.java b/HotelApp/src/main/java/com/yen/HotelApp/entity/Booking.java index e3563f9a4..2c1430b19 100644 --- a/HotelApp/src/main/java/com/yen/HotelApp/entity/Booking.java +++ b/HotelApp/src/main/java/com/yen/HotelApp/entity/Booking.java @@ -32,9 +32,12 @@ public class Booking { @Enumerated(EnumType.STRING) private BookingStatus status = BookingStatus.CONFIRMED; + + @Column + private String preparedTransactionId; public enum BookingStatus { - CONFIRMED, CANCELLED, COMPLETED + CONFIRMED, CANCELLED, COMPLETED, PREPARED } public Booking() {} @@ -112,4 +115,12 @@ public BookingStatus getStatus() { public void setStatus(BookingStatus status) { this.status = status; } + + public String getPreparedTransactionId() { + return preparedTransactionId; + } + + public void setPreparedTransactionId(String preparedTransactionId) { + this.preparedTransactionId = preparedTransactionId; + } } \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/entity/Room.java b/HotelApp/src/main/java/com/yen/HotelApp/entity/Room.java index a44d14aa1..d09113978 100644 --- a/HotelApp/src/main/java/com/yen/HotelApp/entity/Room.java +++ b/HotelApp/src/main/java/com/yen/HotelApp/entity/Room.java @@ -23,6 +23,9 @@ public class Room { private Boolean available = true; private String description; + + @Column + private String preparedTransactionId; public Room() {} @@ -81,4 +84,12 @@ public String getDescription() { public void setDescription(String description) { this.description = description; } + + public String getPreparedTransactionId() { + return preparedTransactionId; + } + + public void setPreparedTransactionId(String preparedTransactionId) { + this.preparedTransactionId = preparedTransactionId; + } } \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/service/participant/BookingServiceParticipant.java b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/BookingServiceParticipant.java new file mode 100644 index 000000000..fe0a403a2 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/BookingServiceParticipant.java @@ -0,0 +1,190 @@ +package com.yen.HotelApp.service.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yen.HotelApp.dto.BookingRequest; +import com.yen.HotelApp.entity.Booking; +import com.yen.HotelApp.entity.PreparedTransaction; +import com.yen.HotelApp.entity.Room; +import com.yen.HotelApp.repository.BookingRepository; +import com.yen.HotelApp.repository.PreparedTransactionRepository; +import com.yen.HotelApp.repository.RoomRepository; +import com.yen.HotelApp.transaction.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +public class BookingServiceParticipant implements TransactionParticipant { + + private static final Logger logger = LoggerFactory.getLogger(BookingServiceParticipant.class); + private static final String PARTICIPANT_ID = "BOOKING_SERVICE"; + + @Autowired + private BookingRepository bookingRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private PreparedTransactionRepository preparedTransactionRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Override + @Transactional + public ParticipantState prepare(String transactionId, TransactionContext context) { + logger.info("Preparing booking creation for transaction: {}", transactionId); + + try { + BookingRequest request = (BookingRequest) context.getTransactionData(); + + Optional roomOpt = roomRepository.findById(request.getRoomId()); + if (roomOpt.isEmpty()) { + logger.warn("Room not found: {} for transaction: {}", request.getRoomId(), transactionId); + return ParticipantState.ABORTED; + } + + Room room = roomOpt.get(); + + if (request.getCheckInDate().isAfter(request.getCheckOutDate()) || + request.getCheckInDate().isEqual(request.getCheckOutDate())) { + logger.warn("Invalid date range for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + Booking booking = new Booking(); + booking.setRoom(room); + booking.setGuestName(request.getGuestName()); + booking.setGuestEmail(request.getGuestEmail()); + booking.setCheckInDate(request.getCheckInDate()); + booking.setCheckOutDate(request.getCheckOutDate()); + booking.setTotalPrice(request.getTotalPrice()); + booking.setStatus(Booking.BookingStatus.PREPARED); + booking.setPreparedTransactionId(transactionId); + + Booking savedBooking = bookingRepository.save(booking); + + PreparedTransaction preparedTx = new PreparedTransaction( + transactionId, + PARTICIPANT_ID, + serializeBookingData(savedBooking), + LocalDateTime.now().plusSeconds(context.getTimeoutSeconds()) + ); + preparedTransactionRepository.save(preparedTx); + + logger.info("Booking prepared for transaction: {} with booking ID: {}", transactionId, savedBooking.getId()); + return ParticipantState.PREPARED; + + } catch (Exception e) { + logger.error("Error preparing booking for transaction: {}", transactionId, e); + return ParticipantState.ABORTED; + } + } + + @Override + @Transactional + public void commit(String transactionId) { + logger.info("Committing booking for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared transaction found for commit: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + Booking bookingData = deserializeBookingData(preparedTx.getPreparedData()); + + Optional bookingOpt = bookingRepository.findById(bookingData.getId()); + if (bookingOpt.isPresent()) { + Booking booking = bookingOpt.get(); + booking.setStatus(Booking.BookingStatus.CONFIRMED); + booking.setPreparedTransactionId(null); + bookingRepository.save(booking); + + logger.info("Booking {} committed for transaction: {}", booking.getId(), transactionId); + } + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (Exception e) { + logger.error("Error committing booking for transaction: {}", transactionId, e); + } + } + + @Override + @Transactional + public void rollback(String transactionId) { + logger.info("Rolling back booking for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared transaction found for rollback: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + Booking bookingData = deserializeBookingData(preparedTx.getPreparedData()); + + Optional bookingOpt = bookingRepository.findById(bookingData.getId()); + if (bookingOpt.isPresent()) { + Booking booking = bookingOpt.get(); + booking.setStatus(Booking.BookingStatus.CANCELLED); + bookingRepository.save(booking); + + logger.info("Booking {} rolled back for transaction: {}", booking.getId(), transactionId); + } + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (Exception e) { + logger.error("Error rolling back booking for transaction: {}", transactionId, e); + } + } + + @Override + public String getParticipantId() { + return PARTICIPANT_ID; + } + + @Override + public boolean canTimeout() { + return true; + } + + @Override + public int getTimeoutSeconds() { + return 300; // 5 minutes + } + + private String serializeBookingData(Booking booking) { + try { + return objectMapper.writeValueAsString(booking); + } catch (JsonProcessingException e) { + logger.error("Error serializing booking data", e); + return null; + } + } + + private Booking deserializeBookingData(String data) { + try { + return objectMapper.readValue(data, Booking.class); + } catch (JsonProcessingException e) { + logger.error("Error deserializing booking data", e); + return null; + } + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/service/participant/NotificationServiceParticipant.java b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/NotificationServiceParticipant.java new file mode 100644 index 000000000..401351e51 --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/NotificationServiceParticipant.java @@ -0,0 +1,261 @@ +package com.yen.HotelApp.service.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yen.HotelApp.dto.BookingRequest; +import com.yen.HotelApp.entity.PreparedTransaction; +import com.yen.HotelApp.repository.PreparedTransactionRepository; +import com.yen.HotelApp.transaction.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; + +@Service +public class NotificationServiceParticipant implements TransactionParticipant { + + private static final Logger logger = LoggerFactory.getLogger(NotificationServiceParticipant.class); + private static final String PARTICIPANT_ID = "NOTIFICATION_SERVICE"; + private static final Random random = new Random(); + + @Autowired + private PreparedTransactionRepository preparedTransactionRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Override + @Transactional + public ParticipantState prepare(String transactionId, TransactionContext context) { + logger.info("Preparing notifications for transaction: {}", transactionId); + + try { + BookingRequest request = (BookingRequest) context.getTransactionData(); + + if (request.getGuestEmail() == null || request.getGuestEmail().trim().isEmpty()) { + logger.warn("Missing guest email for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + if (!isValidEmail(request.getGuestEmail())) { + logger.warn("Invalid email format for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + Thread.sleep(random.nextInt(300) + 100); + + NotificationData notificationData = new NotificationData(); + notificationData.transactionId = transactionId; + notificationData.guestEmail = request.getGuestEmail(); + notificationData.guestName = request.getGuestName(); + notificationData.checkInDate = request.getCheckInDate(); + notificationData.checkOutDate = request.getCheckOutDate(); + notificationData.totalPrice = request.getTotalPrice(); + + NotificationMessage confirmationEmail = new NotificationMessage(); + confirmationEmail.messageId = "EMAIL_" + UUID.randomUUID().toString(); + confirmationEmail.type = "EMAIL"; + confirmationEmail.recipient = request.getGuestEmail(); + confirmationEmail.subject = "Hotel Booking Confirmation"; + confirmationEmail.content = buildConfirmationEmailContent(request); + confirmationEmail.status = "QUEUED"; + + NotificationMessage smsReminder = new NotificationMessage(); + smsReminder.messageId = "SMS_" + UUID.randomUUID().toString(); + smsReminder.type = "SMS"; + smsReminder.recipient = request.getGuestEmail(); // In real scenario, this would be phone number + smsReminder.content = buildSmsReminderContent(request); + smsReminder.status = "QUEUED"; + + notificationData.messages = new ArrayList<>(); + notificationData.messages.add(confirmationEmail); + notificationData.messages.add(smsReminder); + + PreparedTransaction preparedTx = new PreparedTransaction( + transactionId, + PARTICIPANT_ID, + serializeNotificationData(notificationData), + LocalDateTime.now().plusSeconds(context.getTimeoutSeconds()) + ); + preparedTransactionRepository.save(preparedTx); + + logger.info("Notifications prepared for transaction: {} with {} messages", + transactionId, notificationData.messages.size()); + return ParticipantState.PREPARED; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Notification preparation interrupted for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } catch (Exception e) { + logger.error("Error preparing notifications for transaction: {}", transactionId, e); + return ParticipantState.ABORTED; + } + } + + @Override + @Transactional + public void commit(String transactionId) { + logger.info("Committing notifications for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared notifications found for commit: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + NotificationData notificationData = deserializeNotificationData(preparedTx.getPreparedData()); + + for (NotificationMessage message : notificationData.messages) { + Thread.sleep(random.nextInt(200) + 100); + + // Simulate sending notification with 95% success rate + if (random.nextDouble() < 0.95) { + message.status = "SENT"; + message.sentAt = LocalDateTime.now(); + logger.info("Notification sent: {} to {}", message.messageId, message.recipient); + } else { + message.status = "FAILED"; + message.failureReason = "Delivery timeout"; + logger.warn("Notification failed: {} to {}", message.messageId, message.recipient); + } + } + + logger.info("Notifications committed for transaction: {}", transactionId); + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Notification commit interrupted for transaction: {}", transactionId); + } catch (Exception e) { + logger.error("Error committing notifications for transaction: {}", transactionId, e); + } + } + + @Override + @Transactional + public void rollback(String transactionId) { + logger.info("Rolling back notifications for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared notifications found for rollback: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + NotificationData notificationData = deserializeNotificationData(preparedTx.getPreparedData()); + + Thread.sleep(random.nextInt(100) + 50); + + for (NotificationMessage message : notificationData.messages) { + message.status = "CANCELLED"; + message.cancelledAt = LocalDateTime.now(); + } + + logger.info("Notifications cancelled for transaction: {}", transactionId); + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Notification rollback interrupted for transaction: {}", transactionId); + } catch (Exception e) { + logger.error("Error rolling back notifications for transaction: {}", transactionId, e); + } + } + + @Override + public String getParticipantId() { + return PARTICIPANT_ID; + } + + @Override + public boolean canTimeout() { + return false; // Notifications can be sent later if needed + } + + @Override + public int getTimeoutSeconds() { + return 600; // 10 minutes (longer timeout for notifications) + } + + private boolean isValidEmail(String email) { + return email.contains("@") && email.contains(".") && email.length() > 5; + } + + private String buildConfirmationEmailContent(BookingRequest request) { + return String.format( + "Dear %s,\n\nYour hotel booking has been confirmed!\n\n" + + "Check-in: %s\nCheck-out: %s\nTotal Price: $%.2f\n\n" + + "Thank you for choosing our hotel!\n\nBest regards,\nHotel Management", + request.getGuestName(), + request.getCheckInDate(), + request.getCheckOutDate(), + request.getTotalPrice() + ); + } + + private String buildSmsReminderContent(BookingRequest request) { + return String.format( + "Hotel booking confirmed for %s. Check-in: %s. Total: $%.2f. See you soon!", + request.getGuestName(), + request.getCheckInDate(), + request.getTotalPrice() + ); + } + + private String serializeNotificationData(NotificationData notificationData) { + try { + return objectMapper.writeValueAsString(notificationData); + } catch (JsonProcessingException e) { + logger.error("Error serializing notification data", e); + return null; + } + } + + private NotificationData deserializeNotificationData(String data) { + try { + return objectMapper.readValue(data, NotificationData.class); + } catch (JsonProcessingException e) { + logger.error("Error deserializing notification data", e); + return null; + } + } + + public static class NotificationData { + public String transactionId; + public String guestEmail; + public String guestName; + public java.time.LocalDate checkInDate; + public java.time.LocalDate checkOutDate; + public Double totalPrice; + public List messages; + } + + public static class NotificationMessage { + public String messageId; + public String type; // EMAIL, SMS, PUSH + public String recipient; + public String subject; + public String content; + public String status; // QUEUED, SENT, FAILED, CANCELLED + public String failureReason; + public LocalDateTime sentAt; + public LocalDateTime cancelledAt; + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/service/participant/PaymentServiceParticipant.java b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/PaymentServiceParticipant.java new file mode 100644 index 000000000..cbac6b78b --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/PaymentServiceParticipant.java @@ -0,0 +1,205 @@ +package com.yen.HotelApp.service.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yen.HotelApp.dto.BookingRequest; +import com.yen.HotelApp.entity.PreparedTransaction; +import com.yen.HotelApp.repository.PreparedTransactionRepository; +import com.yen.HotelApp.transaction.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; + +@Service +public class PaymentServiceParticipant implements TransactionParticipant { + + private static final Logger logger = LoggerFactory.getLogger(PaymentServiceParticipant.class); + private static final String PARTICIPANT_ID = "PAYMENT_SERVICE"; + private static final Random random = new Random(); + + @Autowired + private PreparedTransactionRepository preparedTransactionRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Override + @Transactional + public ParticipantState prepare(String transactionId, TransactionContext context) { + logger.info("Preparing payment for transaction: {}", transactionId); + + try { + BookingRequest request = (BookingRequest) context.getTransactionData(); + + if (request.getTotalPrice() == null || request.getTotalPrice() <= 0) { + logger.warn("Invalid payment amount for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + if (request.getPaymentMethod() == null || request.getPaymentMethod().trim().isEmpty()) { + logger.warn("Missing payment method for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + Thread.sleep(random.nextInt(1000) + 500); + + if (random.nextDouble() < 0.1) { + logger.warn("Payment authorization failed for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } + + PaymentData paymentData = new PaymentData(); + paymentData.transactionId = transactionId; + paymentData.amount = request.getTotalPrice(); + paymentData.paymentMethod = request.getPaymentMethod(); + paymentData.paymentToken = request.getPaymentToken(); + paymentData.authorizationId = "AUTH_" + UUID.randomUUID().toString(); + paymentData.status = "AUTHORIZED"; + + PreparedTransaction preparedTx = new PreparedTransaction( + transactionId, + PARTICIPANT_ID, + serializePaymentData(paymentData), + LocalDateTime.now().plusSeconds(context.getTimeoutSeconds()) + ); + preparedTransactionRepository.save(preparedTx); + + logger.info("Payment authorized for transaction: {} with auth ID: {}", + transactionId, paymentData.authorizationId); + return ParticipantState.PREPARED; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Payment preparation interrupted for transaction: {}", transactionId); + return ParticipantState.ABORTED; + } catch (Exception e) { + logger.error("Error preparing payment for transaction: {}", transactionId, e); + return ParticipantState.ABORTED; + } + } + + @Override + @Transactional + public void commit(String transactionId) { + logger.info("Committing payment for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared payment found for commit: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + PaymentData paymentData = deserializePaymentData(preparedTx.getPreparedData()); + + Thread.sleep(random.nextInt(500) + 200); + + paymentData.status = "CAPTURED"; + paymentData.captureId = "CAPTURE_" + UUID.randomUUID().toString(); + paymentData.capturedAt = LocalDateTime.now(); + + logger.info("Payment captured for transaction: {} with capture ID: {}", + transactionId, paymentData.captureId); + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Payment commit interrupted for transaction: {}", transactionId); + } catch (Exception e) { + logger.error("Error committing payment for transaction: {}", transactionId, e); + } + } + + @Override + @Transactional + public void rollback(String transactionId) { + logger.info("Rolling back payment for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared payment found for rollback: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + PaymentData paymentData = deserializePaymentData(preparedTx.getPreparedData()); + + Thread.sleep(random.nextInt(300) + 100); + + paymentData.status = "VOIDED"; + paymentData.voidId = "VOID_" + UUID.randomUUID().toString(); + paymentData.voidedAt = LocalDateTime.now(); + + logger.info("Payment authorization voided for transaction: {} with void ID: {}", + transactionId, paymentData.voidId); + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Payment rollback interrupted for transaction: {}", transactionId); + } catch (Exception e) { + logger.error("Error rolling back payment for transaction: {}", transactionId, e); + } + } + + @Override + public String getParticipantId() { + return PARTICIPANT_ID; + } + + @Override + public boolean canTimeout() { + return true; + } + + @Override + public int getTimeoutSeconds() { + return 180; // 3 minutes (shorter for payment) + } + + private String serializePaymentData(PaymentData paymentData) { + try { + return objectMapper.writeValueAsString(paymentData); + } catch (JsonProcessingException e) { + logger.error("Error serializing payment data", e); + return null; + } + } + + private PaymentData deserializePaymentData(String data) { + try { + return objectMapper.readValue(data, PaymentData.class); + } catch (JsonProcessingException e) { + logger.error("Error deserializing payment data", e); + return null; + } + } + + public static class PaymentData { + public String transactionId; + public Double amount; + public String paymentMethod; + public String paymentToken; + public String authorizationId; + public String captureId; + public String voidId; + public String status; + public LocalDateTime capturedAt; + public LocalDateTime voidedAt; + } +} \ No newline at end of file diff --git a/HotelApp/src/main/java/com/yen/HotelApp/service/participant/RoomServiceParticipant.java b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/RoomServiceParticipant.java new file mode 100644 index 000000000..309ba03ef --- /dev/null +++ b/HotelApp/src/main/java/com/yen/HotelApp/service/participant/RoomServiceParticipant.java @@ -0,0 +1,182 @@ +package com.yen.HotelApp.service.participant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yen.HotelApp.dto.BookingRequest; +import com.yen.HotelApp.entity.PreparedTransaction; +import com.yen.HotelApp.entity.Room; +import com.yen.HotelApp.repository.PreparedTransactionRepository; +import com.yen.HotelApp.repository.RoomRepository; +import com.yen.HotelApp.transaction.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +public class RoomServiceParticipant implements TransactionParticipant { + + private static final Logger logger = LoggerFactory.getLogger(RoomServiceParticipant.class); + private static final String PARTICIPANT_ID = "ROOM_SERVICE"; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private PreparedTransactionRepository preparedTransactionRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Override + @Transactional + public ParticipantState prepare(String transactionId, TransactionContext context) { + logger.info("Preparing room reservation for transaction: {}", transactionId); + + try { + BookingRequest request = (BookingRequest) context.getTransactionData(); + + Optional roomOpt = roomRepository.findById(request.getRoomId()); + if (roomOpt.isEmpty()) { + logger.warn("Room not found: {} for transaction: {}", request.getRoomId(), transactionId); + return ParticipantState.ABORTED; + } + + Room room = roomOpt.get(); + + if (!room.getAvailable()) { + logger.warn("Room not available: {} for transaction: {}", request.getRoomId(), transactionId); + return ParticipantState.ABORTED; + } + + if (room.getPreparedTransactionId() != null) { + logger.warn("Room already reserved in another transaction: {} for transaction: {}", + request.getRoomId(), transactionId); + return ParticipantState.ABORTED; + } + + room.setPreparedTransactionId(transactionId); + room.setAvailable(false); + roomRepository.save(room); + + PreparedTransaction preparedTx = new PreparedTransaction( + transactionId, + PARTICIPANT_ID, + serializeRoomData(room), + LocalDateTime.now().plusSeconds(context.getTimeoutSeconds()) + ); + preparedTransactionRepository.save(preparedTx); + + logger.info("Room {} prepared for transaction: {}", request.getRoomId(), transactionId); + return ParticipantState.PREPARED; + + } catch (Exception e) { + logger.error("Error preparing room for transaction: {}", transactionId, e); + return ParticipantState.ABORTED; + } + } + + @Override + @Transactional + public void commit(String transactionId) { + logger.info("Committing room reservation for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared transaction found for commit: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + Room roomData = deserializeRoomData(preparedTx.getPreparedData()); + + Optional roomOpt = roomRepository.findById(roomData.getId()); + if (roomOpt.isPresent()) { + Room room = roomOpt.get(); + room.setPreparedTransactionId(null); + roomRepository.save(room); + + logger.info("Room {} committed for transaction: {}", room.getId(), transactionId); + } + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (Exception e) { + logger.error("Error committing room for transaction: {}", transactionId, e); + } + } + + @Override + @Transactional + public void rollback(String transactionId) { + logger.info("Rolling back room reservation for transaction: {}", transactionId); + + try { + Optional preparedTxOpt = + preparedTransactionRepository.findByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + if (preparedTxOpt.isEmpty()) { + logger.warn("No prepared transaction found for rollback: {}", transactionId); + return; + } + + PreparedTransaction preparedTx = preparedTxOpt.get(); + Room roomData = deserializeRoomData(preparedTx.getPreparedData()); + + Optional roomOpt = roomRepository.findById(roomData.getId()); + if (roomOpt.isPresent()) { + Room room = roomOpt.get(); + room.setPreparedTransactionId(null); + room.setAvailable(true); + roomRepository.save(room); + + logger.info("Room {} rolled back for transaction: {}", room.getId(), transactionId); + } + + preparedTransactionRepository.deleteByTransactionIdAndParticipantId(transactionId, PARTICIPANT_ID); + + } catch (Exception e) { + logger.error("Error rolling back room for transaction: {}", transactionId, e); + } + } + + @Override + public String getParticipantId() { + return PARTICIPANT_ID; + } + + @Override + public boolean canTimeout() { + return true; + } + + @Override + public int getTimeoutSeconds() { + return 300; // 5 minutes + } + + private String serializeRoomData(Room room) { + try { + return objectMapper.writeValueAsString(room); + } catch (JsonProcessingException e) { + logger.error("Error serializing room data", e); + return null; + } + } + + private Room deserializeRoomData(String data) { + try { + return objectMapper.readValue(data, Room.class); + } catch (JsonProcessingException e) { + logger.error("Error deserializing room data", e); + return null; + } + } +} \ No newline at end of file