Add outbox module: Transactional Outbox with the Spring Modulith Event Publication Registry

This commit is contained in:
2026-10-03 19:27:32 +00:00
parent 866eceed0a
commit d815a37f2e
17 changed files with 721 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
# outbox
Companion code for *[Transactional Outbox with the Spring Modulith Event Publication
Registry](https://ankurm.com)* on [ankurm.com](https://ankurm.com).
A single `billing` module demonstrating the dual-write problem, Spring Modulith's event
publication registry acting as a real transactional outbox, externalizing events to a
real (embedded) Kafka broker via `spring-modulith-events-kafka`, and replaying an
incomplete publication with `IncompleteEventPublications`. Every number in the
companion post traces to `output/`, in order.
## Versions
| Artifact | Version |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Modulith | 2.1.1 (GA; verified against Maven Central's `maven-metadata.xml`) |
| spring-modulith-events-kafka | 2.1.1 |
| spring-kafka / spring-kafka-test | 4.1.1 (managed by the Spring Boot 4.1.1 BOM; `kafka-clients` 4.2.1) |
| JDK | 25 (LTS) |
| H2 | runtime, in-memory, for the demo only |
## Quickstart
```
mvn -pl outbox -am test -Dtest=DualWriteProblemTest
mvn -pl outbox -am test -Dtest=RegistrySafetyNetTest
mvn -pl outbox -am test -Dtest=ReplayIncompletePublicationsTest
```
Run separately on purpose -- `RegistrySafetyNetTest` starts a real embedded Kafka
broker (`@EmbeddedKafka`), which takes several seconds and is unrelated to what the
other two tests are checking.
## What each class is
| Class | Role |
|---|---|
| `billing.BillingManagement` | The outbox-safe way to issue an invoice: one local transaction, one `Invoice` row, one event publication registry row |
| `billing.NaiveBillingService` | The "before" picture -- a separate, uncoordinated `kafka.send()` call after the database write already committed. Never wired as the module's real API; only ever called directly from `DualWriteProblemTest` |
| `billing.InvoiceIssued` | `@Externalized("invoices::#{#this.invoiceId}")` -- routes to the `invoices` Kafka topic, keyed by the invoice id |
| `billing.FlakyAuditListener` (test-only) | Throws on its first invocation, succeeds after -- stands in for any downstream dependency being briefly unavailable, without needing to actually break a running broker mid-test |
## Captured output (`output/`)
| File | What it captures |
|---|---|
| `00-dual-write-problem.txt` | The invoice committed, Kafka never received either message, and a real stack trace showing `KafkaTemplate.send()` throwing *synchronously* when the broker is totally unreachable -- narrower than, and a correction to, the usual "fire-and-forget send() fails silently" claim |
| `01-registry-safety-net.txt` | The real production path: a real embedded Kafka broker receiving the real message, and the registry moving both listeners' rows into `EVENT_PUBLICATION_ARCHIVE` (`completion-mode=ARCHIVE` is turned on for this module -- the previous post in this series left it commented out) |
| `02-replay-incomplete-publications.txt` | A listener failing once, the registry leaving its row incomplete, and `IncompleteEventPublications.resubmitIncompletePublicationsOlderThan(Duration)` -- a real method, verified against the compiled `events-api` jar -- redelivering it successfully |
No `docs/` chapter directory in this repository -- the intermediate and reference-depth
material that would normally live there is in accordion sections inside the WordPress
post itself.
+35
View File
@@ -0,0 +1,35 @@
$ mvn -pl outbox -am test -Dtest=DualWriteProblemTest
(NaiveBillingService: save the Invoice, then separately call kafka.send() -- no
coordinator between the two. spring.kafka.bootstrap-servers points at localhost:19999,
nothing listening there)
2026-10-04T00:52:56.429+05:30 ERROR 5271 --- [outbox] [ main] o.s.k.support.LoggingProducerListener : Exception thrown when sending a message with key='1' and payload='WIDGET-1:1999' to topic invoices-naive:
org.apache.kafka.common.errors.TimeoutException: Topic invoices-naive not present in metadata after 2000 ms.
Exception thrown synchronously from issueInvoiceTheNaiveWay(): org.springframework.kafka.KafkaException: Send failed
Invoice count regardless: 1 (was 0). The database write already committed inside saveInvoiceOnly() before this exception was ever thrown -- the exception came from the Kafka call, several lines later, on an already-committed row.
2026-10-04T00:52:58.495+05:30 ERROR 5271 --- [outbox] [ main] o.s.k.support.LoggingProducerListener : Exception thrown when sending a message with key='2' and payload='WIDGET-1:2999' to topic invoices-naive:
org.apache.kafka.common.errors.TimeoutException: Topic invoices-naive not present in metadata after 2000 ms.
Invoice count after the blocked, failed send: 2 (was 1). The row committed before the send was even attempted.
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Two real findings here, not one. First, the obvious one: the invoice exists in the
database in both tests, and Kafka never received either message -- that's the dual-write
problem, reproduced with nothing faked beyond pointing Kafka at a port with no listener.
Second, a more specific one that contradicts the usual "fire-and-forget send() fails
silently" framing: when the broker can't be reached to fetch topic metadata at all,
Spring's KafkaTemplate.send() throws synchronously, wrapping the Kafka client's own
TimeoutException in a KafkaException, even though the method's declared return type is
a CompletableFuture. The "silent" version of this bug is real, but it is a narrower
case than total broker unavailability -- it's what happens when the broker is reachable
and the topic's metadata is known, but the specific produce request times out waiting
for an acknowledgment afterward. That failure mode surfaces only through the returned
future or a callback, never as a synchronous exception, and is not reproduced in this
transcript (it needs a broker that accepts the connection and then stops responding
mid-request, not one that was never there to begin with).
+20
View File
@@ -0,0 +1,20 @@
$ mvn -pl outbox -am test -Dtest=RegistrySafetyNetTest
(real embedded Kafka broker via @EmbeddedKafka; BillingManagement.issueInvoice()
publishes InvoiceIssued, @Externalized("invoices::#{#this.invoiceId}") routes it
through spring-modulith-events-kafka)
Kafka record received -- topic=invoices key=1 value={"invoiceId":1,"sku":"GADGET-7","amountCents":4500}
EVENT_PUBLICATION_ARCHIVE rows for InvoiceIssued: 2
EVENT_PUBLICATION rows still outstanding for InvoiceIssued: 0
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
The message really arrived on the real topic, with the real routing key (the invoice's
own id, exactly as the SpEL expression after "::" in @Externalized specifies) and the
real JSON payload. Two archive rows for InvoiceIssued, not one, because this module's
test sources also declare FlakyAuditListener (see output/02) -- it is a second,
independent listener on the same event, and with completion-mode=ARCHIVE configured in
application.properties, both listeners' completed publications moved out of
EVENT_PUBLICATION and into EVENT_PUBLICATION_ARCHIVE once they succeeded. Zero rows
left outstanding in EVENT_PUBLICATION for this event type confirms neither listener is
still pending.
@@ -0,0 +1,23 @@
$ mvn -pl outbox -am test -Dtest=ReplayIncompletePublicationsTest
(FlakyAuditListener throws on its first invocation of InvoiceIssued, succeeds on every
one after -- standing in for any downstream dependency that is briefly unavailable)
Incomplete FlakyAuditListener publications after the simulated failure: 1
Incomplete FlakyAuditListener publications after resubmission: 0
Invoice 1 never changed -- the retry was purely about redelivering the event, the invoice row was correct the whole time.
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
The sequence that actually ran: BillingManagement.issueInvoice() commits the Invoice
row and the EVENT_PUBLICATION row for FlakyAuditListener in one local transaction.
FlakyAuditListener.on(InvoiceIssued) is then invoked asynchronously, throws on purpose,
and the registry leaves that row with COMPLETION_DATE still null -- "incomplete" is
exactly that: a row with no completion date, regardless of whether the listener ever
ran at all or ran and failed. Calling
IncompleteEventPublications.resubmitIncompletePublicationsOlderThan(Duration.ZERO) --
a real method on a real interface, verified against the compiled events-api 2.1.1 jar,
not assumed from documentation prose -- re-invokes the listener for every such row.
This time failNextAttempt is already false, so it succeeds, and the row's completion
date is set. The invoice itself was never touched by any of this: the retry is about
redelivering a notification, not about redoing a database write that already
succeeded the first time.
+94
View File
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>spring-modulith-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>outbox</artifactId>
<packaging>jar</packaging>
<name>outbox</name>
<description>
Companion code for "Transactional Outbox with the Spring Modulith Event Publication
Registry" on ankurm.com.
</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-bom</artifactId>
<version>${spring-modulith.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-events-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,13 @@
package com.ankurm.outboxdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.modulith.Modulithic;
@SpringBootApplication
@Modulithic(systemName = "Outbox Demo")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@@ -0,0 +1,35 @@
package com.ankurm.outboxdemo.billing;
import com.ankurm.outboxdemo.billing.internal.Invoice;
import com.ankurm.outboxdemo.billing.internal.InvoiceRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The outbox-safe way to issue an invoice. The {@code Invoice} row and the event
* publication registry row for {@link InvoiceIssued} are written in the same local
* database transaction -- that insert is what makes this an outbox, not the event
* itself. Whatever happens to the event afterwards (externalized to Kafka, picked up
* by an in-process listener, or nothing at all because the process dies one instruction
* later) cannot un-happen the fact that a row recording "this needs to go out" already
* made it to disk, atomically with the invoice itself.
*/
@Service
public class BillingManagement {
private final InvoiceRepository invoices;
private final ApplicationEventPublisher events;
public BillingManagement(InvoiceRepository invoices, ApplicationEventPublisher events) {
this.invoices = invoices;
this.events = events;
}
@Transactional
public Invoice issueInvoice(String sku, int amountCents) {
var invoice = invoices.save(new Invoice(sku, amountCents, "ISSUED"));
events.publishEvent(new InvoiceIssued(invoice.getId(), invoice.getSku(), invoice.getAmountCents()));
return invoice;
}
}
@@ -0,0 +1,16 @@
package com.ankurm.outboxdemo.billing;
import org.springframework.modulith.events.Externalized;
/**
* {@code @Externalized}'s routing syntax is {@code "target::key"} -- everything before
* {@code ::} is the Kafka topic, everything after is a SpEL expression (root object
* {@code #this}) used as the message key. Verified against the compiled annotation
* (org.springframework.modulith.events.Externalized, 2.1.1): it is {@code @Target(TYPE)},
* one aliased {@code value()/target()} String attribute, nothing else -- the routing
* syntax itself is parsed at runtime by the externalization machinery, not encoded in
* the annotation's own shape.
*/
@Externalized("invoices::#{#this.invoiceId}")
public record InvoiceIssued(Long invoiceId, String sku, int amountCents) {
}
@@ -0,0 +1,62 @@
package com.ankurm.outboxdemo.billing;
import com.ankurm.outboxdemo.billing.internal.Invoice;
import com.ankurm.outboxdemo.billing.internal.InvoiceRepository;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The two-systems-no-coordinator version, kept here deliberately as the "before"
* picture -- it is never wired as the module's real API, only invoked directly from
* {@code DualWriteProblemTest}. The database write commits and returns before this
* method ever reaches the Kafka call: there is no transaction, local or distributed,
* spanning both. If the process dies, or the broker is unreachable, between those two
* lines, the invoice exists and nothing downstream will ever hear about it.
*/
@Service
public class NaiveBillingService {
private final InvoiceRepository invoices;
private final KafkaTemplate<String, String> kafka;
public NaiveBillingService(InvoiceRepository invoices, KafkaTemplate<String, String> kafka) {
this.invoices = invoices;
this.kafka = kafka;
}
@Transactional
public Invoice saveInvoiceOnly(String sku, int amountCents) {
return invoices.save(new Invoice(sku, amountCents, "ISSUED"));
}
/**
* Deliberately two separate steps: {@link #saveInvoiceOnly} commits and returns
* on its own transaction boundary before this method ever calls {@code kafka.send}.
* Fire-and-forget: the returned {@code CompletableFuture} is never inspected, which
* is the common case at most real call sites. If the broker is unreachable, this
* method returns successfully anyway -- the failure happens on a Kafka client
* thread, after this method has already returned, and nothing here ever sees it.
*/
public Invoice issueInvoiceTheNaiveWay(String sku, int amountCents) {
var invoice = saveInvoiceOnly(sku, amountCents);
kafka.send("invoices-naive", invoice.getId().toString(),
invoice.getSku() + ":" + invoice.getAmountCents());
return invoice;
}
/**
* Same two-step shortcut, except this call site does the "responsible" thing and
* blocks on the send future so it can react to a failure. It still doesn't help:
* the database write already committed in {@link #saveInvoiceOnly} by the time this
* line runs, so catching the exception here can log it, but cannot undo the invoice
* that is already sitting in the database with nothing downstream ever told about it.
*/
public Invoice issueInvoiceAndBlockOnTheSend(String sku, int amountCents) throws Exception {
var invoice = saveInvoiceOnly(sku, amountCents);
kafka.send("invoices-naive", invoice.getId().toString(),
invoice.getSku() + ":" + invoice.getAmountCents())
.get(3, java.util.concurrent.TimeUnit.SECONDS);
return invoice;
}
}
@@ -0,0 +1,50 @@
package com.ankurm.outboxdemo.billing.internal;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Deliberately named Invoice, not Order -- the previous post in this series
* (output/00-table-name-collision.txt in the order-fulfillment module) already
* spent a transcript on why ORDER is a reserved SQL keyword. This module picks a
* domain noun that doesn't collide, on purpose.
*/
@Entity
@Table(name = "invoices")
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private int amountCents;
private String status;
protected Invoice() {
}
public Invoice(String sku, int amountCents, String status) {
this.sku = sku;
this.amountCents = amountCents;
this.status = status;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public int getAmountCents() {
return amountCents;
}
public String getStatus() {
return status;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.outboxdemo.billing.internal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
}
@@ -0,0 +1,19 @@
spring.application.name=outbox
spring.datasource.url=jdbc:h2:mem:outbox;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
# The event publication registry is the outbox table. JDBC-backed here (not JPA) so
# the registry's own schema is independent of whatever ORM the business entities use.
spring.modulith.events.jdbc.schema-initialization.enabled=true
# ARCHIVE, this time -- the previous post in this series left this commented out so
# every transcript there ran against the library's bare UPDATE default. This module is
# about the outbox actually being operated correctly, so it is turned on here instead.
spring.modulith.events.completion-mode=ARCHIVE
spring.modulith.events.republish-outstanding-events-on-restart=true
logging.level.com.ankurm.outboxdemo=INFO
logging.level.org.springframework.modulith.events=INFO
@@ -0,0 +1,80 @@
package com.ankurm.outboxdemo.billing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.ankurm.outboxdemo.billing.internal.InvoiceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
/**
* No embedded broker here, on purpose -- spring.kafka.bootstrap-servers points at a
* port nothing is listening on, which is the simplest honest way to reproduce "the
* broker is unreachable" without faking anything. Two real, independent systems,
* called one after the other, is already enough to produce the dual-write problem;
* nothing here is simulated beyond pointing Kafka at an address with no listener.
*/
@SpringBootTest
class DualWriteProblemTest {
@DynamicPropertySource
static void unreachableKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", () -> "localhost:19999");
registry.add("spring.kafka.producer.properties.max.block.ms", () -> "2000");
}
@Autowired
NaiveBillingService naiveBillingService;
@Autowired
InvoiceRepository invoices;
@Test
void fireAndForgetSendStillThrowsWhenTheBrokerIsTotallyUnreachable() {
long before = invoices.count();
// Expected this call to return cleanly and lose the failure silently --
// KafkaTemplate.send() returns a CompletableFuture, so a caller that ignores it
// "shouldn't" see an exception. Running it for real shows otherwise: when the
// broker can't even be reached to fetch topic metadata, Spring's KafkaTemplate
// wraps that specific failure and rethrows it synchronously from send() itself
// (see the real stack trace in the captured transcript). The database write is
// already unaffected either way -- it's a separate, already-committed transaction
// by the time this line runs -- but the exception itself is not silent here.
Exception thrown = null;
try {
naiveBillingService.issueInvoiceTheNaiveWay("WIDGET-1", 1999);
} catch (Exception e) {
thrown = e;
}
assertThat(thrown).isNotNull();
assertThat(invoices.count()).isEqualTo(before + 1);
System.out.println("Exception thrown synchronously from issueInvoiceTheNaiveWay(): "
+ thrown.getClass().getName() + ": " + thrown.getMessage());
System.out.println("Invoice count regardless: " + invoices.count() + " (was " + before
+ "). The database write already committed inside saveInvoiceOnly() before this"
+ " exception was ever thrown -- the exception came from the Kafka call, several"
+ " lines later, on an already-committed row.");
}
@Test
void blockingOnTheFutureSurfacesTheSameFailureAndStillCannotUndoTheWrite() {
long before = invoices.count();
assertThatThrownBy(() -> naiveBillingService.issueInvoiceAndBlockOnTheSend("WIDGET-1", 2999))
.isInstanceOf(org.springframework.kafka.KafkaException.class)
.hasCauseInstanceOf(org.apache.kafka.common.errors.TimeoutException.class);
// The invoice is still there. Blocking on the future told us about the Kafka
// failure a little sooner, but by the time we could react to it,
// saveInvoiceOnly() had already committed on its own transaction boundary --
// there was nothing left to roll back.
assertThat(invoices.count()).isEqualTo(before + 1);
System.out.println("Invoice count after the blocked, failed send: " + invoices.count()
+ " (was " + before + "). The row committed before the send was even attempted.");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.outboxdemo.billing;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
/**
* Test-only listener, never part of the packaged application (it lives under
* src/test, so component scanning only ever picks it up inside a test's
* ApplicationContext). Stands in for "the thing that can transiently fail" --
* in a real system this is the Kafka broker being briefly unreachable, a REST
* call to a partner timing out, or any other downstream dependency with its own
* availability. The event publication registry doesn't know or care which;
* it only knows whether THIS listener, for THIS event, has completed yet.
*/
@Component
public class FlakyAuditListener {
public static final AtomicInteger attempts = new AtomicInteger(0);
public static volatile boolean failNextAttempt = false;
@ApplicationModuleListener
public void on(InvoiceIssued event) {
int attempt = attempts.incrementAndGet();
if (failNextAttempt) {
failNextAttempt = false;
throw new IllegalStateException(
"simulated downstream failure on attempt " + attempt + " for invoice " + event.invoiceId());
}
}
}
@@ -0,0 +1,91 @@
package com.ankurm.outboxdemo.billing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import com.ankurm.outboxdemo.billing.internal.InvoiceRepository;
import java.time.Duration;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.test.context.EmbeddedKafka;
/**
* The real production path: BillingManagement.issueInvoice() publishes InvoiceIssued,
* which is @Externalized, which spring-modulith-events-kafka routes to a real broker --
* a genuine embedded Kafka instance here, not a mock. Proves two things at once: the
* message really arrives on the topic, and the registry really marks that listener's
* row complete (and, with completion-mode=ARCHIVE configured for this module, moves it
* into EVENT_PUBLICATION_ARCHIVE) once it does.
*/
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "invoices", bootstrapServersProperty = "spring.kafka.bootstrap-servers")
class RegistrySafetyNetTest {
@Autowired
BillingManagement billingManagement;
@Autowired
InvoiceRepository invoices;
@Autowired
JdbcTemplate jdbc;
@Autowired
org.springframework.core.env.Environment env;
KafkaConsumer<String, String> consumer;
@AfterEach
void closeConsumer() {
if (consumer != null) {
consumer.close();
}
}
@Test
void invoiceIssuedIsReallyPublishedToKafkaAndRegistryCompletes() {
String bootstrapServers = env.getProperty("spring.kafka.bootstrap-servers");
consumer = new KafkaConsumer<>(Map.of(
"bootstrap.servers", bootstrapServers,
"group.id", "test-group",
"auto.offset.reset", "earliest",
"key.deserializer", StringDeserializer.class.getName(),
"value.deserializer", StringDeserializer.class.getName()));
consumer.subscribe(java.util.List.of("invoices"));
var invoice = billingManagement.issueInvoice("GADGET-7", 4500);
assertThat(invoices.findById(invoice.getId())).isPresent();
var records = new java.util.ArrayList<ConsumerRecord<String, String>>();
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
consumer.poll(Duration.ofMillis(200)).forEach(records::add);
assertThat(records).isNotEmpty();
});
ConsumerRecord<String, String> received = records.get(0);
assertThat(received.topic()).isEqualTo("invoices");
assertThat(received.key()).isEqualTo(invoice.getId().toString());
System.out.println("Kafka record received -- topic=" + received.topic()
+ " key=" + received.key() + " value=" + received.value());
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
Integer archived = jdbc.queryForObject(
"select count(*) from EVENT_PUBLICATION_ARCHIVE where EVENT_TYPE like '%InvoiceIssued%'",
Integer.class);
System.out.println("EVENT_PUBLICATION_ARCHIVE rows for InvoiceIssued: " + archived);
assertThat(archived).isGreaterThanOrEqualTo(1);
});
Integer stillOutstanding = jdbc.queryForObject(
"select count(*) from EVENT_PUBLICATION where EVENT_TYPE like '%InvoiceIssued%'",
Integer.class);
System.out.println("EVENT_PUBLICATION rows still outstanding for InvoiceIssued: " + stillOutstanding);
}
}
@@ -0,0 +1,90 @@
package com.ankurm.outboxdemo.billing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.modulith.events.IncompleteEventPublications;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
/**
* FlakyAuditListener throws on its first invocation and succeeds on every one after
* that. This is standing in for "the downstream dependency was briefly unavailable" --
* a Kafka broker, a partner API, anything -- without needing to actually break a real
* broker's availability mid-test. What is NOT simulated is the registry behavior: the
* incomplete publication really is left in EVENT_PUBLICATION with no completion date
* after the first failure, and resubmitIncompletePublicationsOlderThan really does
* re-invoke the listener and really does mark the row complete on success. Verified
* against the compiled IncompleteEventPublications interface (events-api 2.1.1):
* resubmitIncompletePublicationsOlderThan(Duration) is a real method, not a guess.
*/
@SpringBootTest
class ReplayIncompletePublicationsTest {
// This test is about the FlakyAuditListener, not Kafka -- but spring-modulith-events-kafka
// is still on the classpath and still tries to externalize InvoiceIssued to a default
// localhost:9092 that doesn't exist here. Trimmed down purely so the JVM doesn't sit
// retrying metadata fetches for 30 seconds during shutdown; it does not affect what this
// test is actually asserting.
@DynamicPropertySource
static void fastFailKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.producer.properties.max.block.ms", () -> "500");
}
@Autowired
BillingManagement billingManagement;
@Autowired
IncompleteEventPublications incompletePublications;
@Autowired
JdbcTemplate jdbc;
@BeforeEach
void reset() {
FlakyAuditListener.attempts.set(0);
FlakyAuditListener.failNextAttempt = false;
}
@Test
void failedListenerLeavesAnIncompletePublicationThatResubmissionCompletes() {
FlakyAuditListener.failNextAttempt = true;
var invoice = billingManagement.issueInvoice("GIZMO-3", 999);
await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
assertThat(FlakyAuditListener.attempts.get()).isEqualTo(1));
Integer incompleteAfterFailure = jdbc.queryForObject(
"select count(*) from EVENT_PUBLICATION where EVENT_TYPE like '%InvoiceIssued%' "
+ "and LISTENER_ID like '%FlakyAuditListener%' and COMPLETION_DATE is null",
Integer.class);
System.out.println("Incomplete FlakyAuditListener publications after the simulated failure: "
+ incompleteAfterFailure);
assertThat(incompleteAfterFailure).isEqualTo(1);
incompletePublications.resubmitIncompletePublicationsOlderThan(Duration.ZERO);
await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
assertThat(FlakyAuditListener.attempts.get()).isEqualTo(2));
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
Integer incompleteAfterResubmit = jdbc.queryForObject(
"select count(*) from EVENT_PUBLICATION where EVENT_TYPE like '%InvoiceIssued%' "
+ "and LISTENER_ID like '%FlakyAuditListener%' and COMPLETION_DATE is null",
Integer.class);
System.out.println("Incomplete FlakyAuditListener publications after resubmission: "
+ incompleteAfterResubmit);
assertThat(incompleteAfterResubmit).isEqualTo(0);
});
System.out.println("Invoice " + invoice.getId() + " never changed -- the retry was purely"
+ " about redelivering the event, the invoice row was correct the whole time.");
}
}
+2
View File
@@ -14,6 +14,7 @@
Companion repository for the ankurm.com Spring Modulith series.
Each Maven module backs one post; modules are added over time, one commit each.
- order-fulfillment : Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith
- outbox : Transactional Outbox with the Spring Modulith Event Publication Registry
</description>
<properties>
@@ -25,5 +26,6 @@
<modules>
<module>order-fulfillment</module>
<module>outbox</module>
</modules>
</project>