Skip to content

Commit 4107dce

Browse files
Add transactional outbox with trace propagation (#7)
* Add notify-spring-boot-outbox module skeleton with status and entry types * Add outbox properties, store SPI, and canonical schema * Add JdbcOutboxStore with SKIP LOCKED claim, integration-tested on Postgres * Add OutboxNotifier API and JSON-serializing DefaultOutboxNotifier * Add OutboxRelay: poll, deliver via Notifier, exponential backoff * Add OutboxAutoConfiguration wiring the store, notifier, and scheduled relay Tests for the outbox module are deferred and will be written from scratch. * Use Jackson 3 JsonMapper in the outbox, not Jackson 2 ObjectMapper Matches the rest of the project (tools.jackson). Jackson 3 throws unchecked JacksonException, so the rewrapping catch blocks are gone. * Fail the entry, not the poll cycle, on an undeserializable outbox payload * Auto-configure the outbox on a DataSource and schedule the relay programmatically Drops the redundant spring.notify.outbox.enabled flag — adding the module is the opt-in, matching how the channel modules activate. The relay now runs on the application TaskScheduler at the interval from OutboxProperties, so the poll interval has a single source of truth. Documents the feature. * Correct the outbox store SPI docs: a replacement must still join the caller's transaction * Generate outbox config metadata and declare dependencies explicitly Adds the configuration-processor so spring.notify.outbox.* keys autocomplete, with additional metadata correcting the two int defaults the processor reads as 0. Drops the unused notify-spring-boot-autoconfigure dependency and declares spring-context, spring-tx, and jackson-core instead of relying on transitives. * Add outbox-email example: demonstrates transactional outbox with PostgreSQL Creates examples/outbox-email — a Spring Boot app proving the transactional outbox guarantee: notifications enqueued in a business transaction are delivered if the transaction commits, never sent if it rolls back. Demonstrates Order flow using JdbcClient, OutboxNotifier, and OutboxRelay. Integration-tested with Testcontainers (PostgreSQL + Mailpit) for end-to-end verification of commit/rollback semantics. * Give the outbox its own scheduler so apps need not enable scheduling The relay required a TaskScheduler bean, which Boot only auto-configures when the application opts into scheduling — so using the outbox meant annotating your own application class. It now supplies a dedicated single-thread scheduler, keeping relay polling off the application's shared scheduler too. Also marks the new API @SInCE 1.1.0 and returns Optional from the example repository instead of null. * Restore outbox test dependencies * Test DefaultOutboxNotifier: pending entry, type, payload, id * Test OutboxProperties defaults and overrides * Test OutboxRelay: delivery, retry scheduling, backoff, poison pills * Test JdbcOutboxStore against Postgres using the shipped schema * Test that concurrent relays claim disjoint entries via SKIP LOCKED * Test outbox auto-configuration, scheduler, properties, and store override * Keep the relay polling through store failures and use RecordingNotifier in tests poll() no longer lets a claimBatch failure escape to the scheduler: a database outage is logged and the next interval retries, instead of dumping a stack trace every cycle (and risking the scheduled task being cancelled). Replaces the hand-written notifier stub with RecordingNotifier from notify-test. * Add OutboxTracePropagator SPI with a no-op implementation * Share one recording outbox store across the tests * Return Optional from OutboxTracePropagator.capture() * Persist a trace_context column on outbox entries * Add a Micrometer-based outbox trace propagator * Capture the trace context at enqueue and restore it for delivery * Auto-configure real trace propagation when tracing is present * Select the outbox trace propagator independently of bean declaration order The no-op fallback relied on the nested tracing configuration being processed first; a reorder would have silently disabled propagation with no test failing. It is now conditional on the tracing beans being absent. * Log the outbox lifecycle: relay start/stop, enqueue, and delivery * Prove outbox trace propagation end to end and document it * Prove the restored trace context parents the outbox delivery
1 parent 342fbea commit 4107dce

43 files changed

Lines changed: 2915 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@ docs/superpowers/
1212

1313
# private working notes (pitch, plan, design, handoff)
1414
.notes/
15+
16+
# local-only Maven settings (mirror workaround); never committed — breaks release deploy
17+
.mvn/settings.xml
18+
.mvn/maven.config

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ documents the request fields.
105105
dead-lettering, or retry. → [Observability & events](docs/observability-and-events.md#events)
106106
- **Interceptors** — wrap every send (or one channel) for logging, rate limiting, retry, kill
107107
switches. → [Interceptors](docs/interceptors.md)
108+
- **Transactional outbox** — persist a send in your business transaction; a relay delivers it
109+
durably afterwards, safe across instances. → [Transactional outbox](docs/outbox.md)
108110
- **Test double** — `RecordingNotifier` asserts sends without a real provider.
109111
→ [Testing](docs/testing.md)
110112

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ notifier.notify(SmsRequest.builder()
2020
- [Providers](providers.md) — the provider matrix and per-provider configuration.
2121
- [Interceptors](interceptors.md) — wrap every send for logging, rate limiting, retry, kill switches.
2222
- [Observability & events](observability-and-events.md) — Micrometer observation, lifecycle events, interceptor ordering.
23+
- [Transactional outbox](outbox.md) — durable sends that commit with your business transaction.
2324
- [Testing](testing.md) — asserting notifications with `RecordingNotifier`.
2425

2526
> Latest release: `1.0.0` (on Maven Central) · Java 25 · Spring Boot 4.1.

docs/outbox.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Transactional outbox
2+
3+
The `notify-spring-boot-outbox` module makes a send **durable and transactionally consistent**:
4+
you persist the notification in the same database transaction as your business change, and a
5+
background relay delivers it afterwards.
6+
7+
```xml
8+
<dependency>
9+
<groupId>sk.solodev</groupId>
10+
<artifactId>notify-spring-boot-outbox</artifactId>
11+
</dependency>
12+
```
13+
14+
## Why
15+
16+
`notifier.notify(request)` calls the provider inline. That leaves two gaps:
17+
18+
- **Lost notification** — the business transaction commits, then the process dies before the
19+
provider call completes. The customer never hears about their order.
20+
- **Phantom notification** — you send first, then the surrounding transaction rolls back. The
21+
customer is told about an order that does not exist.
22+
23+
The outbox closes both. If the transaction commits, the notification *will* be delivered; if it
24+
rolls back, nothing is sent.
25+
26+
## Use
27+
28+
Inject `OutboxNotifier` instead of `Notifier` and call `enqueue` inside a transactional method:
29+
30+
```java
31+
@Service
32+
class OrderService {
33+
34+
private final OrderRepository orders;
35+
private final OutboxNotifier outbox;
36+
37+
OrderService(OrderRepository orders, OutboxNotifier outbox) {
38+
this.orders = orders;
39+
this.outbox = outbox;
40+
}
41+
42+
@Transactional
43+
public void placeOrder(Order order) {
44+
orders.save(order);
45+
// Written in the same transaction as the save: roll back and nothing is sent.
46+
outbox.enqueue(EmailRequest.builder()
47+
.to(order.customerEmail())
48+
.from("shop@example.com")
49+
.subject("Order received")
50+
.body("Thanks for your order!")
51+
.build());
52+
}
53+
}
54+
```
55+
56+
`enqueue` returns the **outbox entry id**, not a provider message id — at that point nothing has
57+
been sent yet. Use the id to correlate with the `NotificationSent` / `NotificationFailed` event
58+
published when the relay actually delivers.
59+
60+
Which bean you inject *is* the durability choice: `Notifier` sends inline, `OutboxNotifier`
61+
persists for durable delivery. Both can be used in the same application.
62+
63+
## The database table
64+
65+
The outbox needs one table. The module ships the canonical DDL as
66+
`schema-notification-outbox.sql` (on the classpath), but **does not run it** — your application
67+
owns its schema:
68+
69+
- **Production** — copy it into your Flyway/Liquibase migrations.
70+
- **Dev/test** — point `spring.sql.init.schema-locations` at
71+
`classpath:schema-notification-outbox.sql`.
72+
73+
The DDL is written for PostgreSQL. For MySQL 8+, use `CHAR(36)` for `id` and `DATETIME(6)` for the
74+
timestamps. The relay's claim query uses `FOR UPDATE SKIP LOCKED`, which requires **PostgreSQL or
75+
MySQL 8.0+**.
76+
77+
The table includes:
78+
- `id` — outbox entry id (UUID)
79+
- `request_type` / `payload` — the serialized notification request
80+
- `status` / `attempts` / `max_attempts` — relay state
81+
- `message_id` / `last_error` — delivery outcome
82+
- `created_at` / `next_attempt_at` / `sent_at` — timing
83+
- `trace_context` — W3C trace parent (VARCHAR(512), nullable)
84+
85+
Override the table name with `spring.notify.outbox.table-name` if `notification_outbox` clashes.
86+
87+
**Existing deployments:** if you are adding the outbox module to a running application, migrate the
88+
schema:
89+
90+
```sql
91+
ALTER TABLE notification_outbox ADD COLUMN trace_context VARCHAR(512);
92+
```
93+
94+
## How delivery works
95+
96+
```
97+
@Transactional method
98+
orders.save(order)
99+
outbox.enqueue(request) ──► INSERT ... status=PENDING (same transaction)
100+
commit ──► row is durable
101+
102+
OutboxRelay (every poll-interval, on every instance)
103+
SELECT ... WHERE status='PENDING' AND next_attempt_at <= now
104+
FOR UPDATE SKIP LOCKED LIMIT batch-size
105+
per row: deserialize ──► notifier.notify(request) ──► status=SENT, message_id
106+
on failure ──► attempts++, next_attempt_at = now + backoff (or FAILED)
107+
```
108+
109+
The relay delivers through the **normal `Notifier` pipeline**, so your interceptors, events, and
110+
observations all apply at real delivery time — the outbox composes on top of them rather than
111+
bypassing them.
112+
113+
**Multi-instance safe.** `SKIP LOCKED` means concurrent relays on different instances claim
114+
disjoint rows, so you can run the outbox on every instance without double-sending. No leader
115+
election required.
116+
117+
## Observability
118+
119+
The enqueue and the eventual delivery are linked into one trace. When you call
120+
`outbox.enqueue(request)` inside a transactional method, the outbox captures the ambient W3C trace
121+
context and stores it in the `trace_context` column. Later, when the `OutboxRelay` delivers the
122+
entry, it restores that context around the delivery so the delivery span descends from the request
123+
that enqueued it — the full notification flow appears as a single, connected trace despite the
124+
durable boundary.
125+
126+
This requires **micrometer-tracing** and a bridge (e.g.
127+
`io.micrometer:micrometer-tracing-bridge-brave`) on the classpath. Without tracing, the outbox
128+
works exactly as before: the `trace_context` column stays null, and each delivery starts an
129+
unparented span.
130+
131+
Each retry is its own child span under the original enqueue trace. If a stored context cannot be
132+
restored — the serialization format changed between enqueue and delivery — the delivery degrades to
133+
an unparented span rather than failing.
134+
135+
## Retries and failure
136+
137+
A failed delivery increments `attempts` and schedules the next try with exponential backoff
138+
(doubling from `initial-backoff`, capped at `max-backoff`). Once `attempts` reaches
139+
`max-attempts`, the entry becomes `FAILED` and is no longer retried; `last_error` holds the final
140+
message.
141+
142+
A payload that cannot be deserialized at all — the request class was renamed or removed, or the
143+
stored JSON no longer matches it — is marked `FAILED` immediately rather than retried, since it can
144+
never succeed. This keeps one bad row from blocking every other entry in the batch.
145+
146+
Retries are durable and cross-poll: `attempts` and `next_attempt_at` live in the table, so a pending
147+
retry survives restarts and redeploys. If you also write a retrying
148+
[interceptor](interceptors.md), note that its attempts sit *inside* one relay attempt, so the two
149+
counts multiply.
150+
151+
**Delivery is at-least-once.** If the process dies after the provider accepts the message but
152+
before the status update commits, the relay will deliver that entry again on a later poll. Design
153+
consumers accordingly, or make the sends idempotent at the provider level.
154+
155+
## Configuration
156+
157+
| Key | Default | Meaning |
158+
|----------------------------------------|-----------------------|--------------------------------------------------|
159+
| `spring.notify.outbox.poll-interval` | `PT5S` | how often the relay polls |
160+
| `spring.notify.outbox.batch-size` | `100` | rows claimed per poll |
161+
| `spring.notify.outbox.max-attempts` | `5` | attempts before an entry is `FAILED` |
162+
| `spring.notify.outbox.initial-backoff` | `PT10S` | delay before the first retry |
163+
| `spring.notify.outbox.max-backoff` | `PT10M` | cap on the exponential backoff |
164+
| `spring.notify.outbox.table-name` | `notification_outbox` | outbox table name |
165+
166+
```yaml
167+
spring:
168+
notify:
169+
outbox:
170+
poll-interval: 2s
171+
max-attempts: 8
172+
```
173+
174+
Adding the module is the opt-in — every key above is optional. The outbox auto-configures as soon as
175+
a `DataSource` bean is present.
176+
177+
## Supplying your own store
178+
179+
Persistence sits behind the `OutboxStore` SPI (`insert`, `claimBatch`, `markSent`, `markForRetry`,
180+
`markFailed`). The JDBC implementation is registered `@ConditionalOnMissingBean`, so defining your
181+
own `OutboxStore` bean replaces it — useful for a different claim strategy, a dialect the shipped SQL
182+
does not cover, a table that does not match the canonical layout, or wrapping the store to add
183+
metrics.
184+
185+
Whatever you implement, `insert` must enlist in the caller's transaction — that is what makes the
186+
notification commit atomically with the business change. In practice this means a store backed by the
187+
same transactional datasource as the application; a store that commits independently gives up the
188+
guarantee the outbox exists to provide.

examples/outbox-email/pom.xml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
6+
<parent>
7+
<groupId>org.springframework.boot</groupId>
8+
<artifactId>spring-boot-starter-parent</artifactId>
9+
<version>4.1.0</version>
10+
<relativePath/>
11+
</parent>
12+
13+
<groupId>sk.solodev.examples</groupId>
14+
<artifactId>outbox-email</artifactId>
15+
<version>1.0.0-SNAPSHOT</version>
16+
<name>spring-notify :: examples :: outbox-email</name>
17+
<description>Transactional outbox pattern: enqueues notifications in a business transaction, delivers via relay. Integration-tested with PostgreSQL + Mailpit to prove commit/rollback guarantees.</description>
18+
19+
<properties>
20+
<java.version>25</java.version>
21+
<spring-notify.version>1.0.1-SNAPSHOT</spring-notify.version>
22+
</properties>
23+
24+
<dependencyManagement>
25+
<dependencies>
26+
<dependency>
27+
<groupId>sk.solodev</groupId>
28+
<artifactId>notify-bom</artifactId>
29+
<version>${spring-notify.version}</version>
30+
<type>pom</type>
31+
<scope>import</scope>
32+
</dependency>
33+
</dependencies>
34+
</dependencyManagement>
35+
36+
<dependencies>
37+
<dependency>
38+
<groupId>org.springframework.boot</groupId>
39+
<artifactId>spring-boot-starter-webmvc</artifactId>
40+
</dependency>
41+
<dependency>
42+
<groupId>org.springframework.boot</groupId>
43+
<artifactId>spring-boot-starter-jdbc</artifactId>
44+
</dependency>
45+
<dependency>
46+
<groupId>sk.solodev</groupId>
47+
<artifactId>notify-spring-boot-outbox</artifactId>
48+
</dependency>
49+
<dependency>
50+
<groupId>sk.solodev</groupId>
51+
<artifactId>notify-spring-boot-starter-email-smtp</artifactId>
52+
</dependency>
53+
<dependency>
54+
<groupId>org.postgresql</groupId>
55+
<artifactId>postgresql</artifactId>
56+
<scope>runtime</scope>
57+
</dependency>
58+
59+
<dependency>
60+
<groupId>org.springframework.boot</groupId>
61+
<artifactId>spring-boot-starter-webmvc-test</artifactId>
62+
<scope>test</scope>
63+
</dependency>
64+
<dependency>
65+
<groupId>org.springframework.boot</groupId>
66+
<artifactId>spring-boot-testcontainers</artifactId>
67+
<scope>test</scope>
68+
</dependency>
69+
<dependency>
70+
<groupId>org.testcontainers</groupId>
71+
<artifactId>testcontainers-junit-jupiter</artifactId>
72+
<scope>test</scope>
73+
</dependency>
74+
<dependency>
75+
<groupId>org.testcontainers</groupId>
76+
<artifactId>testcontainers-postgresql</artifactId>
77+
<scope>test</scope>
78+
</dependency>
79+
</dependencies>
80+
81+
<build>
82+
<plugins>
83+
<plugin>
84+
<groupId>org.springframework.boot</groupId>
85+
<artifactId>spring-boot-maven-plugin</artifactId>
86+
</plugin>
87+
</plugins>
88+
</build>
89+
</project>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.outbox;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class Application {
8+
9+
static void main(String[] args) {
10+
SpringApplication.run(Application.class, args);
11+
}
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.example.outbox;
2+
3+
import java.math.BigDecimal;
4+
import java.util.UUID;
5+
6+
record Order(UUID id, String customerEmail, String productName, BigDecimal amount) {
7+
8+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.example.outbox;
2+
3+
import org.springframework.web.bind.annotation.PostMapping;
4+
import org.springframework.web.bind.annotation.RequestBody;
5+
import org.springframework.web.bind.annotation.RequestParam;
6+
import org.springframework.web.bind.annotation.RestController;
7+
8+
import java.util.Map;
9+
import java.util.UUID;
10+
11+
@RestController
12+
class OrderController {
13+
14+
private final OrderService orderService;
15+
16+
OrderController(OrderService orderService) {
17+
this.orderService = orderService;
18+
}
19+
20+
@PostMapping("/orders")
21+
Map<String, UUID> createOrder(@RequestBody Order order,
22+
@RequestParam(defaultValue = "false") boolean fail) {
23+
var orderId = orderService.placeOrder(order, fail);
24+
return Map.of("orderId", orderId);
25+
}
26+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.example.outbox;
2+
3+
import org.springframework.jdbc.core.simple.JdbcClient;
4+
import org.springframework.stereotype.Repository;
5+
6+
import java.util.Optional;
7+
import java.util.UUID;
8+
9+
@Repository
10+
class OrderRepository {
11+
12+
private final JdbcClient jdbc;
13+
14+
OrderRepository(JdbcClient jdbc) {
15+
this.jdbc = jdbc;
16+
}
17+
18+
void insert(Order order) {
19+
jdbc.sql("""
20+
INSERT INTO orders (id, customer_email, product_name, amount)
21+
VALUES (:id, :customerEmail, :productName, :amount)
22+
""")
23+
.param("id", order.id())
24+
.param("customerEmail", order.customerEmail())
25+
.param("productName", order.productName())
26+
.param("amount", order.amount())
27+
.update();
28+
}
29+
30+
Optional<Order> findById(UUID id) {
31+
return jdbc.sql("SELECT id, customer_email, product_name, amount FROM orders WHERE id = :id")
32+
.param("id", id)
33+
.query((rs, rowNum) -> new Order(
34+
(UUID) rs.getObject("id"),
35+
rs.getString("customer_email"),
36+
rs.getString("product_name"),
37+
rs.getBigDecimal("amount")
38+
))
39+
.optional();
40+
}
41+
}

0 commit comments

Comments
 (0)