diff --git a/microservices-transactional-outbox/README.md b/microservices-transactional-outbox/README.md new file mode 100644 index 000000000000..05201761a1a0 --- /dev/null +++ b/microservices-transactional-outbox/README.md @@ -0,0 +1,184 @@ +--- +title: "Microservices Transactional Outbox Pattern in Java: Ensuring Reliable Messaging" +shortTitle: Transactional Outbox +description: "Learn how the Transactional Outbox pattern guarantees reliable message delivery between microservices by leveraging a local database transaction, achieving eventual consistency." +category: Integration +language: en +tag: + - Microservices + - Messaging + - Fault tolerance + - Decoupling + - Data consistency + - Enterprise patterns +--- + +## Also known as + +* Outbox Pattern +* Reliable Messaging Pattern + +## Intent of Microservices Transactional Outbox Design Pattern + +To ensure that messages are reliably sent from a microservice as part of a single, atomic database transaction, preventing data loss and inconsistencies in distributed systems. + +## Detailed Explanation of Microservices Transactional Outbox Pattern with Real-World Examples + +Real-world example +> Imagine an e-commerce platform's "Order Service." When a new order is placed, the service must save the order to its database and also notify a separate "Notification Service" to send a confirmation email. If the Order Service first saves the order and then tries to publish a message, the message broker could be down, resulting in an order being created without a notification. Conversely, if it sends the message first and then the database commit fails, a notification is sent for an order that doesn't exist. The Transactional Outbox pattern solves this by saving the new order and the "email notification" event into an `outbox` table within the same database transaction. A separate process then reads from this `outbox` table and reliably sends the event to the Notification Service, guaranteeing that a notification is sent if, and only if, the order was successfully created. + +In plain words +> Atomically save your business data and the messages about those changes in your local database before sending them to other services. + +Chris Richardson's "microservices.io" says +> The Transactional Outbox pattern ensures that a message is sent if and only if the database transaction that creates the event commits. The service that sends the message has an "outbox" table in its database. When it sends a message, it inserts the message into the outbox table as part of the same transaction that updates its business entities. A separate message relay process reads the outbox table and publishes the messages to a message broker. + +Flowchart + +![Microservices Transactional Outbox flowchart](./etc/microservices-transactional-outbox-flowchart.png) + +## Programmatic Example of Microservices Transactional Outbox Pattern in Java + +This example demonstrates the Transactional Outbox pattern for a `CustomerService`. When a new customer is created, the business data is saved, and a corresponding event is stored in an `outbox` table within the same transaction. A background poller then reads these events and sends them to a message broker. + +The `OutboxEvent` entity represents a record in our `outbox` table. + +```java +@Entity +@Table(name = "OUTBOX") +public class OutboxEvent { + + @Id + @GeneratedValue + private Integer id; + + private String eventType; + private String payload; // Typically a JSON string + private boolean processed; + private LocalDateTime createdAt; + + // Constructors, Getters, and Setters +} +``` + +The `CustomerService` handles the business logic. It saves a new `Customer` and an `OutboxEvent` in a single, atomic database transaction. + +```java +public class CustomerService { + + private final EntityManager entityManager; + private final OutboxRepository outboxRepository; + + public void createCustomer(String username) throws Exception { + entityManager.getTransaction().begin(); + try { + // 1. Save the business entity + var customer = new Customer(username); + entityManager.persist(customer); + + // 2. Create and save the outbox event in the same transaction + String payload = new ObjectMapper().writeValueAsString(customer); + var event = new OutboxEvent("CUSTOMER_CREATED", payload); + outboxRepository.save(event); + + // 3. Commit the single transaction + entityManager.getTransaction().commit(); + } catch (Exception e) { + entityManager.getTransaction().rollback(); + throw e; + } + } +} +``` + +The `EventPoller` acts as the separate process that reads from the outbox and publishes messages. + +```java +public class EventPoller { + + private final EntityManager entityManager; + private final OutboxRepository outboxRepository; + private final MessageBroker messageBroker; + + public void start() { + // Polls the database at a fixed rate + } + + private void processOutboxEvents() { + entityManager.getTransaction().begin(); + try { + List events = outboxRepository.findUnprocessedEvents(); + for (var event : events) { + messageBroker.sendMessage(event); + outboxRepository.markAsProcessed(event); + } + entityManager.getTransaction().commit(); + } catch (Exception e) { + entityManager.getTransaction().rollback(); + } + } +} +``` + +The main application starts the services and simulates customer creation. + +```java +public class App { + + public static void main(String[] args) throws Exception { + var entityManagerFactory = Persistence.createEntityManagerFactory("transactional-outbox-pu"); + var entityManager = entityManagerFactory.createEntityManager(); + + var customerService = new CustomerService(entityManager); + var messageBroker = new MessageBroker(); + var eventPoller = new EventPoller(entityManager, messageBroker); + + // Start the background poller + eventPoller.start(); + + // Simulate application logic + customerService.createCustomer("john.doe"); + + // Shutdown + eventPoller.stop(); + } +} +``` +## When to Use the Microservices Transactional Outbox Pattern in Java + +* When you need to guarantee that an event or message is published after a database transaction successfully commits. +* In distributed systems where you need to reliably communicate state changes between services. +* When using asynchronous communication patterns to improve resilience and decoupling but cannot afford to lose messages. +* To avoid dual-write problems where a service needs to write to its own database and send a message as a single atomic operation. + +## Real-World Applications of Microservices Transactional Outbox Pattern in Java + +* E-commerce platforms for reliably handling order creation, payment confirmation, and shipping notification events. +* Financial systems for ensuring that transaction notifications and audit logs are created and sent reliably. +* Booking and reservation systems where a confirmed booking must trigger reliable notifications to other systems (e.g., inventory, customer communication) + +## Benefits and Trade-offs of Microservices Transactional Outbox Pattern + +Benefits: + +* `Reliability`: Guarantees at-least-once delivery of messages, as the event is persisted within the same transaction as the business data. +* `Data Consistency`: Prevents inconsistencies between a service's internal state and the messages it sends to other services. +* `Decoupling`: The service's business logic is completely decoupled from the complexities of message publishing, retries, and failure handling. + +Trade-offs: + +* `Increased Complexity`: Requires an additional `outbox` database table and a separate message relay/polling process. +* `Latency`: Messages are not sent in real-time. There is a delay between the transaction commit and the message being published by the poller. +* `Potential` for Duplicate Messages: Because it ensures at-least-once delivery, consumers of the messages must be designed to be idempotent to handle potential duplicates. + +## Related Java Design Patterns + +* `Saga Pattern`: The Transactional Outbox pattern is a common and reliable way to implement the steps in a Saga, ensuring that commands or events are published reliably between saga participants. +* `Publish/Subscribe`: The outbox poller typically publishes messages to a topic on a message broker, which are then consumed by one or more subscribers. +* `Event Sourcing`: While different, both patterns involve persisting state changes as a sequence of events. The outbox pattern can be used to reliably publish events generated in an Event Sourcing system. + +## References and Credits + +* [Pattern: Transactional Outbox (microservices.io)](https://microservices.io/patterns/data/transactional-outbox.html) +* [Outbox Pattern for Microservices Architectures](https://medium.com/design-microservices-architecture-with-patterns/outbox-pattern-for-microservices-architectures-1b8648dfaa27) +* [Outbox Pattern in Microservices](https://www.baeldung.com/cs/outbox-pattern-microservices) \ No newline at end of file diff --git a/microservices-transactional-outbox/etc/microservices-transactional-outbox-flowchart.png b/microservices-transactional-outbox/etc/microservices-transactional-outbox-flowchart.png new file mode 100644 index 000000000000..db9017050e9e Binary files /dev/null and b/microservices-transactional-outbox/etc/microservices-transactional-outbox-flowchart.png differ diff --git a/microservices-transactional-outbox/etc/microservices-transactional-outbox.puml b/microservices-transactional-outbox/etc/microservices-transactional-outbox.puml new file mode 100644 index 000000000000..2563dbc0f3a6 --- /dev/null +++ b/microservices-transactional-outbox/etc/microservices-transactional-outbox.puml @@ -0,0 +1,69 @@ +@startuml +title Transactional Outbox Pattern Class Diagram + +package com.iluwatar.transactionaloutbox { + + class App { + + {static} main(args: String[]): void + } + + class Customer { + - id: Integer + - username: String + + Customer(username: String) + } + + class OutboxEvent { + - id: Integer + - eventType: String + - payload: String + - processed: boolean + + OutboxEvent(eventType: String, payload: String) + } + + class CustomerService { + - entityManager: EntityManager + - outboxRepository: OutboxRepository + + CustomerService(entityManager: EntityManager) + + createCustomer(username: String): void + } + + class OutboxRepository { + - entityManager: EntityManager + + OutboxRepository(entityManager: EntityManager) + + save(event: OutboxEvent): void + + markAsProcessed(event: OutboxEvent): void + + findUnprocessedEvents(): List + } + + class EventPoller { + - outboxRepository: OutboxRepository + - messageBroker: MessageBroker + + EventPoller(entityManager: EntityManager, messageBroker: MessageBroker) + + start(): void + + stop(): void + - processOutboxEvents(): void + } + + class MessageBroker { + + sendMessage(event: OutboxEvent): void + } +} + +' --- Relationships --- + +App ..> CustomerService : creates > +App ..> EventPoller : creates > +App ..> MessageBroker : creates > + +CustomerService --> "-outboxRepository" OutboxRepository +CustomerService ..> Customer : <> +CustomerService ..> OutboxEvent : <> + +EventPoller --> "-outboxRepository" OutboxRepository +EventPoller --> "-messageBroker" MessageBroker + +OutboxRepository ..> OutboxEvent : <> +MessageBroker ..> OutboxEvent : <> + +@enduml \ No newline at end of file diff --git a/microservices-transactional-outbox/pom.xml b/microservices-transactional-outbox/pom.xml new file mode 100644 index 000000000000..16cc2f7adab5 --- /dev/null +++ b/microservices-transactional-outbox/pom.xml @@ -0,0 +1,100 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + + microservices-transactional-outbox + + + + org.slf4j + slf4j-api + + + jakarta.persistence + jakarta.persistence-api + 3.1.0 + + + ch.qos.logback + logback-classic + + + org.hibernate.orm + hibernate-core + 6.4.4.Final + + + com.h2database + h2 + 2.2.224 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-junit-jupiter + 5.16.1 + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.transactionaloutbox.App + + + + + + + + + diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/App.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/App.java new file mode 100644 index 000000000000..20c3018d44e3 --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/App.java @@ -0,0 +1,57 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import jakarta.persistence.Persistence; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class App { + + public static void main(String[] args) throws Exception { + var entityManagerFactory = Persistence.createEntityManagerFactory("transactional-outbox-pu"); + var entityManager = entityManagerFactory.createEntityManager(); + + var customerService = new CustomerService(entityManager); + var messageBroker = new MessageBroker(); + var eventPoller = new EventPoller(entityManager, messageBroker); + + eventPoller.start(); + + LOGGER.info("Running simulation..."); + Thread.sleep(1000); + + customerService.createCustomer("john.doe"); + Thread.sleep(5000); + + customerService.createCustomer("jane.doe"); + Thread.sleep(5000); + + eventPoller.stop(); + entityManager.close(); + entityManagerFactory.close(); + LOGGER.info("Simulation finished."); + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/Customer.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/Customer.java new file mode 100644 index 000000000000..8be0cf70e904 --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/Customer.java @@ -0,0 +1,43 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Entity +public class Customer { + + private final String username; + @Setter @Id @GeneratedValue private Integer id; + + public Customer(String username) { + this.username = username; + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/CustomerService.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/CustomerService.java new file mode 100644 index 000000000000..76ad46fd13c3 --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/CustomerService.java @@ -0,0 +1,62 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomerService { + + private final EntityManager entityManager; + private final OutboxRepository outboxRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public CustomerService(EntityManager entityManager) { + this.entityManager = entityManager; + this.outboxRepository = new OutboxRepository(entityManager); + } + + public void createCustomer(String username) throws Exception { + entityManager.getTransaction().begin(); + try { + var customer = new Customer(username); + entityManager.persist(customer); + + String payload = objectMapper.writeValueAsString(customer); + var event = new OutboxEvent("CUSTOMER_CREATED", payload); + outboxRepository.save(event); + + entityManager.getTransaction().commit(); + LOGGER.info("SUCCESS: Customer and OutboxEvent saved transactionally."); + + } catch (Exception e) { + entityManager.getTransaction().rollback(); + LOGGER.error("ERROR: Transaction rolled back."); + throw e; + } + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/EventPoller.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/EventPoller.java new file mode 100644 index 000000000000..5464ef7862af --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/EventPoller.java @@ -0,0 +1,122 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.iluwatar.transactionaloutbox; + +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EventPoller { + + private static final Logger LOGGER = LoggerFactory.getLogger(EventPoller.class); + private static final int MAX_RETRIES = 3; + private static final long RETRY_DELAY_MS = 1000; + private static final java.security.SecureRandom RANDOM = new java.security.SecureRandom(); + + @Getter + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + private final EntityManager entityManager; + private final OutboxRepository outboxRepository; + private final MessageBroker messageBroker; + @Getter private long processedEventsCount = 0; + @Getter private long failedEventsCount = 0; + + public EventPoller(EntityManager entityManager, MessageBroker messageBroker) { + this.entityManager = entityManager; + this.outboxRepository = new OutboxRepository(entityManager); + this.messageBroker = messageBroker; + } + + public void start() { + scheduler.scheduleAtFixedRate(this::processOutboxEvents, 0, 2, TimeUnit.SECONDS); + LOGGER.info("EventPoller started."); + } + + public void stop() { + scheduler.shutdown(); + LOGGER.info( + "EventPoller stopped with {} events processed and {} failures.", + processedEventsCount, + failedEventsCount); + } + + void processOutboxEvents() { + LOGGER.info("Polling for unprocessed events..."); + entityManager.getTransaction().begin(); + try { + List events = outboxRepository.findUnprocessedEvents(); + if (events.isEmpty()) { + LOGGER.info("No new events found."); + } else { + LOGGER.info("Found {} events to process.", events.size()); + for (var event : events) { + processEventWithRetry(event); + } + } + entityManager.getTransaction().commit(); + } catch (Exception e) { + LOGGER.error("Error processing outbox events, rolling back transaction.", e); + entityManager.getTransaction().rollback(); + failedEventsCount++; + } + } + + private void processEventWithRetry(OutboxEvent event) { + int retryCount = 0; + while (retryCount < MAX_RETRIES) { + try { + messageBroker.sendMessage(event); + outboxRepository.markAsProcessed(event); + processedEventsCount++; + LOGGER.info("Successfully processed event."); + return; + } catch (Exception e) { + retryCount++; + if (retryCount < MAX_RETRIES) { + LOGGER.warn( + "Failed to process event (attempt {}/{}). Retrying...", retryCount, MAX_RETRIES); + try { + long sleepTime = + Math.min(RETRY_DELAY_MS * (1L << (retryCount - 1)) + RANDOM.nextLong(100), 10000L); + Thread.sleep(sleepTime); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } else { + LOGGER.error("Failed to process event after {} attempts", MAX_RETRIES); + failedEventsCount++; + } + } + } + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/MessageBroker.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/MessageBroker.java new file mode 100644 index 000000000000..5f3c12c0f30f --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/MessageBroker.java @@ -0,0 +1,44 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MessageBroker { + + public void sendMessage(OutboxEvent event) { + LOGGER.info( + "MESSAGE_BROKER: Sending message... Event ID: {}, Payload: {}", + event.getId(), + event.getPayload()); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + LOGGER.info("MESSAGE_BROKER: Message sent successfully."); + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxEvent.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxEvent.java new file mode 100644 index 000000000000..5288e4bc01ce --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxEvent.java @@ -0,0 +1,74 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "OUTBOX") +public class OutboxEvent { + + private final LocalDateTime createdAt; + @Setter @Getter @Id @GeneratedValue private Integer id; + @Getter private String eventType; + @Setter @Getter private String payload; + @Setter @Getter private boolean processed; + @Setter @Getter private Long sequenceNumber; + + public OutboxEvent() { + this.createdAt = LocalDateTime.now(); + this.processed = false; + } + + public OutboxEvent(String eventType, String payload) { + this(); + this.eventType = eventType; + this.payload = payload; + } + + @Override + public String toString() { + return "OutboxEvent{" + + "id=" + + id + + ", eventType='" + + eventType + + '\'' + + ", payload='" + + payload + + '\'' + + ", processed=" + + processed + + ", createdAt=" + + createdAt + + '}'; + } +} diff --git a/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxRepository.java b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxRepository.java new file mode 100644 index 000000000000..be440d4e0583 --- /dev/null +++ b/microservices-transactional-outbox/src/main/java/com/iluwatar/transactionaloutbox/OutboxRepository.java @@ -0,0 +1,59 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import java.util.List; + +public class OutboxRepository { + + private final EntityManager entityManager; + + public OutboxRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + public void save(OutboxEvent event) { + entityManager.persist(event); + } + + public void markAsProcessed(OutboxEvent event) { + event.setProcessed(true); + entityManager.merge(event); + } + + private static final int BATCH_SIZE = 100; + + public List findUnprocessedEvents() { + return entityManager + .createQuery( + "SELECT e FROM OutboxEvent e WHERE e.processed = false ORDER BY e.sequenceNumber", + OutboxEvent.class) + .setMaxResults(BATCH_SIZE) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .getResultList(); + } +} diff --git a/microservices-transactional-outbox/src/main/resources/META-INF/persistence.xml b/microservices-transactional-outbox/src/main/resources/META-INF/persistence.xml new file mode 100644 index 000000000000..c7314863e1d2 --- /dev/null +++ b/microservices-transactional-outbox/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,22 @@ + + + + org.hibernate.jpa.HibernatePersistenceProvider + com.iluwatar.transactionaloutbox.Customer + com.iluwatar.transactionaloutbox.OutboxEvent + + + + + + + + + + + + \ No newline at end of file diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/AppTest.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/AppTest.java new file mode 100644 index 000000000000..2adfa764b02c --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/AppTest.java @@ -0,0 +1,59 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AppTest { + + @Test + void testMainExecution() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void testSimulateCustomerCreation() { + var entityManagerFactory = mock(EntityManagerFactory.class); + var entityManager = mock(EntityManager.class); + var customerService = new CustomerService(entityManager); + var messageBroker = new MessageBroker(); + var eventPoller = new EventPoller(entityManager, messageBroker); + + assertNotNull(entityManagerFactory); + assertNotNull(entityManager); + assertNotNull(customerService); + assertNotNull(messageBroker); + assertNotNull(eventPoller); + } +} diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/CustomerServiceTests.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/CustomerServiceTests.java new file mode 100644 index 000000000000..f252f21ee46a --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/CustomerServiceTests.java @@ -0,0 +1,82 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityTransaction; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Tests for {@link CustomerService}. */ +@ExtendWith(MockitoExtension.class) +class CustomerServiceTests { + + @Mock private EntityManager entityManager; + + @Mock private EntityTransaction transaction; + + private CustomerService customerService; + + @BeforeEach + void setup() { + when(entityManager.getTransaction()).thenReturn(transaction); + customerService = new CustomerService(entityManager); + } + + @Test + void shouldCreateCustomerAndOutboxEventInSameTransaction() throws Exception { + var username = "testUser"; + + customerService.createCustomer(username); + + verify(transaction).begin(); + verify(entityManager, times(2)).persist(any()); + verify(transaction).commit(); + verify(transaction, never()).rollback(); + } + + @Test + void shouldRollbackTransactionOnFailure() { + + var username = "testUser"; + doThrow(new RuntimeException("Test failure")).when(entityManager).persist(any(Customer.class)); + + assertThrows(Exception.class, () -> customerService.createCustomer(username)); + verify(transaction).begin(); + verify(transaction).rollback(); + verify(transaction, never()).commit(); + } +} diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/EventPollerTests.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/EventPollerTests.java new file mode 100644 index 000000000000..94ec680dfff9 --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/EventPollerTests.java @@ -0,0 +1,177 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.LockModeType; +import jakarta.persistence.TypedQuery; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Tests for {@link EventPoller}. */ +@ExtendWith(MockitoExtension.class) +class EventPollerTests { + + private static final String TEST_EVENT = "TEST_EVENT"; + private static final String TEST_PAYLOAD = "payload"; + + @Mock private EntityManager entityManager; + @Mock private EntityTransaction transaction; + @Mock private MessageBroker messageBroker; + @Mock private TypedQuery query; + private EventPoller eventPoller; + + @BeforeEach + void setup() { + when(entityManager.getTransaction()).thenReturn(transaction); + when(entityManager.createQuery(anyString(), eq(OutboxEvent.class))).thenReturn(query); + when(query.setMaxResults(any(Integer.class))).thenReturn(query); + when(query.setLockMode(any(LockModeType.class))).thenReturn(query); + eventPoller = new EventPoller(entityManager, messageBroker); + } + + @Test + void shouldProcessEventsSuccessfully() { + var event = new OutboxEvent("EVENT_1", "payload1"); + when(query.getResultList()).thenReturn(Collections.singletonList(event)); + + eventPoller.processOutboxEvents(); + + verify(messageBroker).sendMessage(event); + verify(transaction).begin(); + verify(transaction).commit(); + assertEquals(1, eventPoller.getProcessedEventsCount()); + } + + @Test + void shouldHandleFailureAndRetry() { + var event = new OutboxEvent(TEST_EVENT, TEST_PAYLOAD); + when(query.getResultList()).thenReturn(Collections.singletonList(event)); + doThrow(new RuntimeException("First attempt")) + .doNothing() + .when(messageBroker) + .sendMessage(any(OutboxEvent.class)); + + eventPoller.processOutboxEvents(); + + verify(messageBroker, times(2)).sendMessage(event); + assertEquals(1, eventPoller.getProcessedEventsCount()); + assertEquals(0, eventPoller.getFailedEventsCount()); + } + + @Test + void shouldHandleInterruptedThread() { + var event = new OutboxEvent(TEST_EVENT, TEST_PAYLOAD); + when(query.getResultList()).thenReturn(Collections.singletonList(event)); + doThrow(new RuntimeException("Processing fails")) + .when(messageBroker) + .sendMessage(any(OutboxEvent.class)); + Thread.currentThread().interrupt(); + + eventPoller.processOutboxEvents(); + + verify(transaction).begin(); + verify(transaction).commit(); + verify(messageBroker).sendMessage(event); + assertEquals(0, eventPoller.getProcessedEventsCount()); + assertEquals( + 0, eventPoller.getFailedEventsCount(), "Interrupted events are not counted as failures"); + assertTrue(Thread.interrupted(), "Interrupt flag should be preserved"); + } + + @Test + void shouldHandleEmptyEventList() { + when(query.getResultList()).thenReturn(Collections.emptyList()); + + eventPoller.processOutboxEvents(); + + verify(transaction).begin(); + verify(transaction).commit(); + assertEquals(0, eventPoller.getProcessedEventsCount()); + assertEquals(0, eventPoller.getFailedEventsCount()); + } + + @Test + void shouldHandleMaxRetryAttempts() { + var event = new OutboxEvent(TEST_EVENT, TEST_PAYLOAD); + when(query.getResultList()).thenReturn(Collections.singletonList(event)); + doThrow(new RuntimeException("Failed processing")) + .when(messageBroker) + .sendMessage(any(OutboxEvent.class)); + + eventPoller.processOutboxEvents(); + + verify(messageBroker, times(3)).sendMessage(event); + assertEquals(0, eventPoller.getProcessedEventsCount()); + assertEquals(1, eventPoller.getFailedEventsCount()); + } + + @Test + void shouldProcessMultipleEvents() { + var event1 = new OutboxEvent("EVENT_1", "payload1"); + var event2 = new OutboxEvent("EVENT_2", "payload2"); + when(query.getResultList()).thenReturn(java.util.Arrays.asList(event1, event2)); + + eventPoller.processOutboxEvents(); + + verify(messageBroker).sendMessage(event1); + verify(messageBroker).sendMessage(event2); + verify(transaction).begin(); + verify(transaction).commit(); + assertEquals(2, eventPoller.getProcessedEventsCount()); + assertEquals(0, eventPoller.getFailedEventsCount()); + } + + @Test + void shouldRollbackTransactionWhenRepositoryFails() { + var dbException = new RuntimeException("Database connection failed"); + when(query.getResultList()).thenThrow(dbException); + + eventPoller.processOutboxEvents(); + + verify(transaction).begin(); + verify(transaction).rollback(); + verify(transaction, never()).commit(); + + assertEquals(1, eventPoller.getFailedEventsCount()); + assertEquals(0, eventPoller.getProcessedEventsCount()); + } +} diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/MessageBrokerTests.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/MessageBrokerTests.java new file mode 100644 index 000000000000..d4774b3b389e --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/MessageBrokerTests.java @@ -0,0 +1,51 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MessageBrokerTests { + private final MessageBroker messageBroker = new MessageBroker(); + + @Test + void shouldSendMessageSuccessfully() { + var event = new OutboxEvent("TEST_EVENT", "test_payload"); + messageBroker.sendMessage(event); + assertFalse(Thread.interrupted(), "Thread should not be interrupted"); + } + + @Test + void shouldHandleInterruptedException() { + var event = new OutboxEvent("TEST_EVENT", "test_payload"); + Thread.currentThread().interrupt(); + + messageBroker.sendMessage(event); + + assertTrue(Thread.interrupted(), "Thread interrupt flag should be preserved"); + } +} diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxEventTests.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxEventTests.java new file mode 100644 index 000000000000..498b6a89d060 --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxEventTests.java @@ -0,0 +1,97 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Tests for {@link OutboxEvent}. */ +class OutboxEventTests { + + @Test + void newOutboxEventShouldBeUnprocessed() { + var eventType = "CUSTOMER_CREATED"; + var payload = "{\"customerId\":1}"; + + var event = new OutboxEvent(eventType, payload); + + assertNotNull(event); + assertEquals(eventType, event.getEventType()); + assertEquals(payload, event.getPayload()); + assertFalse(event.isProcessed()); + assertNotNull(event.toString(), "toString should include createdAt value"); + } + + @Test + void processedEventShouldBeMarkedAsProcessed() { + var event = new OutboxEvent("TEST_EVENT", "payload"); + event.setId(1); + event.setProcessed(true); + + assertTrue(event.isProcessed()); + assertEquals(Integer.valueOf(1), event.getId()); + } + + @Test + void eventsShouldMaintainSequentialOrder() { + var event1 = new OutboxEvent("EVENT_1", "payload1"); + var event2 = new OutboxEvent("EVENT_2", "payload2"); + + event1.setSequenceNumber(1L); + event2.setSequenceNumber(2L); + + assertEquals(Long.valueOf(1L), event1.getSequenceNumber()); + assertEquals(Long.valueOf(2L), event2.getSequenceNumber()); + assertTrue(event1.getSequenceNumber() < event2.getSequenceNumber()); + } + + @Test + void shouldFormatToStringWithAllFields() { + var event = new OutboxEvent("TEST_EVENT", "payload"); + event.setId(123); + event.setSequenceNumber(456L); + event.setProcessed(true); + + var toString = event.toString(); + + assertTrue(toString.contains("id=123"), "toString should contain id"); + assertTrue(toString.contains("eventType='TEST_EVENT'"), "toString should contain eventType"); + assertTrue(toString.contains("payload='payload'"), "toString should contain payload"); + assertTrue(toString.contains("processed=true"), "toString should contain processed status"); + assertTrue(toString.contains("createdAt="), "toString should contain createdAt timestamp"); + } + + @Test + void defaultConstructorShouldInitializeBasicFields() { + var event = new OutboxEvent(); + + assertNotNull(event.toString(), "toString should include createdAt value"); + assertFalse(event.isProcessed(), "Should not be processed by default"); + } +} diff --git a/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxRepositoryTests.java b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxRepositoryTests.java new file mode 100644 index 000000000000..d69a2076f681 --- /dev/null +++ b/microservices-transactional-outbox/src/test/java/com/iluwatar/transactionaloutbox/OutboxRepositoryTests.java @@ -0,0 +1,60 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.transactionaloutbox; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Tests for {@link OutboxRepository}. */ +@ExtendWith(MockitoExtension.class) +class OutboxRepositoryTests { + + @Mock private EntityManager entityManager; + private OutboxRepository repository; + + @BeforeEach + void setup() { + repository = new OutboxRepository(entityManager); + } + + @Test + void shouldSaveAndMarkEventAsProcessed() { + var event = new OutboxEvent("TEST_EVENT", "payload"); + + repository.save(event); + repository.markAsProcessed(event); + + verify(entityManager).persist(event); + verify(entityManager).merge(event); + assertTrue(event.isProcessed()); + } +} diff --git a/microservices-transactional-outbox/src/test/resources/META-INF/persistence.xml b/microservices-transactional-outbox/src/test/resources/META-INF/persistence.xml new file mode 100644 index 000000000000..5d3a4619055a --- /dev/null +++ b/microservices-transactional-outbox/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,24 @@ + + + + + org.hibernate.jpa.HibernatePersistenceProvider + com.iluwatar.transactionaloutbox.OutboxEvent + com.iluwatar.transactionaloutbox.Customer + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8337c97966da..f288c41f2e15 100644 --- a/pom.xml +++ b/pom.xml @@ -167,6 +167,7 @@ microservices-distributed-tracing microservices-idempotent-consumer microservices-log-aggregation + microservices-transactional-outbox model-view-controller model-view-intent model-view-presenter