Companion to README.md and spec.md. What follows is the V1 → V3 evolution recorded as eleven failure modes (F1–F11). F1–F5 were V1 baselines; v2.0.0–v2.3.0 closed each one (F1–F4 via correctness mechanisms, F5 via parallel batch processing). F6 surfaced at v2.2.0 (multi-instance) and was closed in the same release. F7 was a v2.0.0–v2.2.0 known limitation closed at v2.3.0. F8–F11 are saga-specific failure modes that V3.0.0 introduced and closed in the same release. Each mode has a chaos test that locked in the broken behavior at the version boundary and inverted to assert the fix.
The premise of this build follows the study plan philosophy: you understand a system when you have operated a buggy version of it at 2am. V1 was deliberately built broken in five well-known ways. The chaos suite asserts the V1 brokenness — and the V2 implementation flips each assertion. The git diff on those five test files is the load-bearing portfolio artifact.
| Layer | Tool | Notes |
|---|---|---|
| Broker | apache/kafka:3.8.1 (KRaft) |
Same image as docker-compose; via org.testcontainers.kafka.KafkaContainer |
| Database | postgres:16-alpine |
Flyway migrations applied once per JVM |
| Test runner | JUnit 5 + Awaitility | @Tag("chaos"), opt-in via mvn -P chaos test |
| Isolation | Per-test UUID-suffixed topics; TRUNCATE on shared DB tables before each test |
Tests can run in parallel |
| Determinism | No timing races. Failures are reproduced via offset rewinds (alterConsumerGroupOffsets) and config flips (e.g. enable.auto.commit=false) rather than Thread.sleep |
Each failure mode is described in three parts:
- V1 bug — what failed, and how the V1 test reproduced it deterministically
- V2 fix — the requirement(s) implementing the fix, with code references
- Evidence — the assertion change between V1 and V2 + the actual test output
A client retry on POST /orders (after a network timeout) put two records
on orders with the same orderId. V1 had no idempotency check, so the
fulfillment consumer processed both and the notification service logged two
notifications for one logical order.
producer.send(order); // first send
producer.send(order); // client retry — same orderId
// ...
List<OrderFulfilled> events = TopicTailer.drain(...);
assertEquals(2, events.size()); // V1 baseline: PASSES because the bug is realTwo layers:
- HTTP layer — optional
Idempotency-Keyheader.IdempotencyKeyStore.claimuses PostgresINSERT ... ON CONFLICT (key) DO NOTHING. On replay, returns the originalorderIdand does not produce a Kafka record. (Spec: OPS-17, OPS-103. Code:order-api/.../IdempotencyKeyStore.java) - Consumer layer —
processed_orderstable;FulfillmentConsumerdoesSELECT 1 FROM processed_orders WHERE order_id = ?::uuidbefore any work. (Spec: OPS-101. Code:fulfillment-service/.../ProcessedOrdersStore.java)
The two layers are intentionally redundant — they defend against different failure modes (HTTP retry the broker never sees vs Kafka redelivery the order-api never sees).
- assertEquals(2, events.size(), "V1 baseline: two notifications expected");
+ assertEquals(1, events.size(), "V2 fix: idempotency check absorbs the duplicate");[INFO] Tests run: 1 in F1_DuplicateOrdersTest Time: 4.9 s PASS
The V1 fulfillment consumer used enable.auto.commit=true with the default
5 s commit interval. If the consumer crashed after producing the
OrderFulfilled event but before the next auto-commit tick, on restart the
broker redelivered the original orders record → consumer fulfilled it again
→ second OrderFulfilled event → two notifications.
Rather than racing the 5 s tick, V1 reproduced the post-crash state
deterministically by setting enable.auto.commit=false for the first run
(no commit ever happens) and restarting with the same group.id:
// First run — auto-commit suppressed; record processed; offset stays at 0
firstRun.stop(); // simulates crash before next tick
// Second run — same group.id, default config; record redelivered
List<OrderFulfilled> events = TopicTailer.drain(...);
assertEquals(2, events.size()); // V1: PASSESTwo-part:
enable.auto.commit=falseon the consumer;commitSyncis called only after the DB transaction (state + outbox row) has committed. (Spec: OPS-31. Code:FulfillmentConsumer.run)- The
processed_ordersrow inserted by the first run absorbs the redelivery on the second run — the idempotency check finds it and skips. (Spec: OPS-101.)
The first run now succeeds end-to-end (DB tx + outbox publish). The test
then rewinds the consumer-group offset to 0 via
AdminClient.alterConsumerGroupOffsets to simulate "the V2 consumer crashed
before its commitSync". The second run sees the redelivered record, hits
the idempotency check, and skips. Net: still 1 event.
- assertEquals(2, events.size(), "V1 baseline: redelivery causes a second event");
+ assertEquals(1, events.size(), "V2 fix: idempotency check absorbs redelivery");[INFO] Tests run: 1 in F2_DuplicateNotificationsTest Time: 8.7 s PASS
The opposite race from F2: auto-commit fired after poll() returned a
batch but before the consumer finished processing it. On crash + restart,
the offset was already past the unprocessed record — the order was silently
lost.
Used AdminClient.alterConsumerGroupOffsets to plant a committed offset
past the produced record before the consumer started, simulating "the
auto-commit fired and then we crashed":
producer.send(order);
admin.alterConsumerGroupOffsets(groupId, offsetsAtLogEnd);
// consumer starts; resume offset is past the record
// → 0 events ever produced
assertEquals(0, events.size()); // V1: PASSEScommitSync runs only after the DB tx has committed:
// FulfillmentConsumer.run()
for (rec : batch) {
processOne(rec); // includes DB tx commit
consumer.commitSync(Map.of( // only reached on success
new TopicPartition(rec.topic(), rec.partition()),
new OffsetAndMetadata(rec.offset() + 1)));
}Durable state in Postgres is the gate; the offset cannot advance past it. (Spec: OPS-31, OPS-111.)
The V1 "plant a bad offset" trick no longer reproduces a meaningful
failure — V2 doesn't have the auto-commit race in the first place. V2's
F3_LostOrdersTest therefore asserts the happy path: send 1 order, run V2
consumer, expect 1 event.
- assertEquals(0, events.size(), "V1 baseline: record skipped");
+ assertEquals(1, events.size(), "V2 fix: manual commitSync after DB tx → never lost");[INFO] Tests run: 1 in F3_LostOrdersTest Time: 2.3 s PASS
Records that failed to deserialize threw SerializationException out of
KafkaConsumer.poll(). V1 didn't catch it; the consumer thread terminated.
Worse, on restart the same record at the same offset crashed the consumer
again — the partition stalled forever.
Wrote malformed JSON via a raw KafkaProducer<String, byte[]>, then a valid
order with the same key (so same partition):
raw.send(new ProducerRecord<>(orderTopic, poisonKey, "{not valid json".getBytes())).get();
producer.send(valid);
// consumer crashes on poison; valid record is never reached
Awaitility.await().until(consumer::crashed);
assertTrue(consumer.crashed());
assertEquals(0, validEvents.size()); // V1: PASSESThree changes to FulfillmentConsumer:
- Deserialize values as
byte[], not asOrder. JSON parsing happens insideprocessOnewhere it can be caught. - On parse failure, publish the original raw bytes to
orders.dlqwith diagnostic headers, then commit the source offset, then continue. - Add a
DlqProducerkeyed by source key (preserves partitioning).
(Spec: OPS-32, OPS-121, OPS-122. Code:
FulfillmentConsumer.processOne,
DlqProducer)
- assertTrue(consumer.crashed(), "V1: consumer must terminate on poison");
- assertEquals(0, events.size(), "V1: valid order behind poison is never fulfilled");
+ assertFalse(stack.consumer.crashed(), "V2 fix: consumer no longer crashes on poison");
+ assertEquals(1, events.size(), "V2 fix: valid order behind poison is fulfilled normally");
+ assertEquals(1, dlq.size(), "V2 fix: poison record routed to DLQ");
+ assertTrue(d.headers.containsKey("x-dlq-reason"));
+ assertEquals(orderTopic, d.headers.get("x-dlq-source-topic"));Captured during a manual docker compose up run with a poison message
injected via kafka-console-producer:
$ make consume-dlq
x-dlq-reason:JsonParseException: Unrecognized token 'not': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 5],
x-dlq-source-topic:orders,
x-dlq-source-partition:2,
x-dlq-source-offset:1,
x-dlq-original-timestamp:1777391199602
not-valid-json
$ docker compose ps fulfillment-service
NAME STATUS
ops-fulfillment Up 50 seconds # consumer survived
# pipeline still works:
$ curl -X POST http://localhost:6080/orders -H 'Content-Type: application/json' \
-d '{"customerId":"cust-after-poison","items":[{"sku":"SKU-Z","quantity":1,"unitPrice":3.00}]}'
{"orderId":"4dff686d-..."}
$ make psql
> SELECT count(*) FROM processed_orders;
3 # poison didn't block the next order
[INFO] Tests run: 1 in F4_PoisonMessageTest Time: 3.8 s PASS
A single consumer with 50 ms simulated work cannot keep up with a 500-record burst. Lag grew linearly; notifications arrived minutes late.
| Version | Lag at t=2 s (after 500-record burst) | Mechanism |
|---|---|---|
| V1 | 500 (33 processed) | single-thread, auto-commit |
| v2.0.0 | 469 | single-thread, manual commit (slightly more per-record overhead) |
| v2.1.0 | 0 | virtual-thread parallel batch + async outbox publish |
Real artifacts captured during chaos runs:
$ cat chaos-test/target/lag-v1.txt
burst=500 window=2s lag=500 store=33
$ cat chaos-test/target/lag-v2.1.txt
burst=500 window=2s lag=0
Three changes in FulfillmentConsumer.java:
- Virtual-thread executor —
Executors.newVirtualThreadPerTaskExecutor(). Every record in apoll()batch is submitted as its own task. Java 21 virtual threads don't pin OS threads onThread.sleepor DB I/O. - Bounded concurrency — a
Semaphorecapped by--worker-pool-size(default 32) keeps in-flight work bounded so a pathological burst can't exhaust memory. The Hikari pool (16) is the secondary, lower bound. - Per-batch commit —
commitSyncruns once per batch with the highest successful offset per partition. If any record fails, no offset advances; the batch is replayed and theprocessed_ordersidempotency check (now an atomicINSERT … ON CONFLICT DO NOTHING) absorbs records that already wrote durable state.
Plus one change in OutboxRelay.pollAndPublish:
- Async batch publish — the relay now issues every
producer.send()in the polled batch before awaiting any future, then awaits each. After all acks, a single batchedUPDATE outbox SET published_at = now() WHERE id = ANY(?)marks the whole batch published.
v2.0.0 used SELECT exists() THEN INSERT processed_orders. With v2.1.0
parallelism, two workers in the same poll batch (or split between consumer
and outbox-relay redelivery) could both pass the SELECT, both proceed to
the INSERT, and both write outbox rows for the same orderId — even though
the second INSERT was a no-op due to the PK constraint. F1 went red on the
first parallel run.
The fix is structural: collapse the check and the insert into a single atomic statement and use the row count to decide.
- if (processedStore.exists(c, order.orderId())) {
- c.commit(); return;
- }
- processedStore.insert(c, order.orderId(), order.customerId(), fulfilledAt);
- outboxStore.insert(c, …);
+ boolean claimed = processedStore.insert(c, order.orderId(),
+ order.customerId(), fulfilledAt);
+ if (!claimed) {
+ c.commit(); return;
+ }
+ outboxStore.insert(c, …);The INSERT IS the check. There is no window between them.
- assertTrue(lag > 200, "v2.0.0 keeps the V1 baseline assertion. v2.1.0 will invert.");
+ assertTrue(lag < LAG_THRESHOLD,
+ "v2.1.0 fix: expected lag < " + LAG_THRESHOLD + ", got " + lag);[INFO] Tests run: 1 in F5_ConsumerLagTest Time: 3.0 s PASS
Captured during the same end-to-end run:
$ KEY=$(uuidgen)
$ curl -X POST localhost:6080/orders -H "Idempotency-Key: $KEY" \
-d '{"customerId":"bob","items":[{"sku":"SKU-B","quantity":2,"unitPrice":2.50}]}'
{"orderId":"d8c019c6-aca7-492d-a441-d2a63f5753be"}
# replay — same key, same response, no second Kafka record
$ curl -X POST localhost:6080/orders -H "Idempotency-Key: $KEY" \
-d '{"customerId":"bob","items":[{"sku":"SKU-B","quantity":2,"unitPrice":2.50}]}'
{"orderId":"d8c019c6-aca7-492d-a441-d2a63f5753be"}
$ make psql
> SELECT key, order_id FROM idempotency_keys;
key | order_id
------------------------------------------+--------------------------------------
<KEY> | d8c019c6-aca7-492d-a441-d2a63f5753be
(1 row)
> SELECT order_id, customer_id FROM processed_orders WHERE customer_id='bob';
order_id | customer_id
--------------------------------------+-------------
d8c019c6-aca7-492d-a441-d2a63f5753be | bob
(1 row) # exactly one fulfillment, despite two POSTs
F6 is new in v2.2.0. It didn't exist in V1 (no outbox at all), or in
v2.0.0 / v2.1.0 (single fulfillment-service JVM). The first time the
project runs two fulfillment-service instances against the same
Postgres, both relay threads poll the same
SELECT id, … FROM outbox WHERE published_at IS NULL and both pick up
identical rows. They each publish to Kafka; the notification-service
sees every event twice. Customers get two notifications per order.
OutboxStore.findUnpublished
adds FOR UPDATE SKIP LOCKED. OutboxRelay.pollAndPublish
wraps the entire poll → publish → mark cycle in a single Postgres
transaction so the row-level locks are held across the Kafka publish.
- public List<Pending> findUnpublished(int limit) { /* opens own connection */ }
+ public List<Pending> findUnpublished(Connection c, int limit) {
+ "SELECT … FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT ? " +
+ "FOR UPDATE SKIP LOCKED"
+ }- // v2.1.0: send + mark, no shared transaction
- List<Pending> pending = store.findUnpublished(BATCH);
- producer.send(...).get(); store.markPublishedBatch(ids);
+ // v2.2.0: single tx; lock held across publish
+ try (Connection c = ds.getConnection()) {
+ c.setAutoCommit(false);
+ List<Pending> pending = store.findUnpublished(c, BATCH);
+ for (p : pending) futures.add(producer.send(...));
+ for (f : futures) f.get();
+ store.markPublishedBatch(c, ids);
+ c.commit();
+ }F6_MultiInstanceOutboxRaceTest spawns two V2Stacks sharing the same
broker + Postgres, sends 50 orders, asserts exactly 50 events on
order-events. The pre-fix expected behavior is 100 (every event
duplicated). Test runs in ~5.8 s.
[INFO] Tests run: 1 in F6_MultiInstanceOutboxRaceTest Time: 5.8 s PASS
A docker compose up with the v2.2.0 two-instance topology, then 100
orders POSTed in a tight loop:
$ docker logs ops-fulfillment-1 2>&1 | grep '"relay published count' | wc -l
4 # 4 ticks: count=15, 13, 2, ...
$ docker logs ops-fulfillment-2 2>&1 | grep '"relay published count' | wc -l
7 # 7 ticks: count=13, 11, 13, ...
$ docker exec ops-kafka /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server localhost:9092 --topic order-events \
| awk -F: '{sum+=$3} END {print sum}'
100 # exactly 100 events for 100 orders — no duplicates
Both relays did real work (publishing disjoint rows); the total event count matches the order count exactly.
The outbox relay holds a DB transaction across the Kafka publish so it
can commit markPublishedBatch atomically with the lock release. If the
JVM crashes after Kafka has acked the record but before the
relay's commit() lands, the DB tx rolls back — published_at stays
NULL — so the next relay tick (this instance on restart, or another
instance) picks up the row and republishes. Kafka's idempotent producer
dedupes only within a single producer session; across a crash, the new
session emits the record again. order-events ends up with two records
for the same orderId.
In v2.0.0–v2.2.0, notification-service had no idempotency, so each
duplicate event triggered a duplicate notification. F7 was a documented
known limitation across that whole window.
A processed_notifications table:
CREATE TABLE processed_notifications (
order_id UUID PRIMARY KEY,
notified_at TIMESTAMPTZ NOT NULL DEFAULT now()
);NotificationConsumer.notifyOne
now claims the orderId before logging:
if (!store.claim(event.orderId())) {
log.info("duplicate notification suppressed partition={} offset={}", partition, offset);
return;
}
log.info("Notification sent for order={} customer={} ...");
hook.accept(event);ProcessedNotificationsStore.claim is the same atomic
INSERT … ON CONFLICT DO NOTHING pattern that processed_orders uses;
row count = 1 means we won the race, 0 means a previous delivery already
notified this customer.
F7_NotificationDedupTest publishes two identical OrderFulfilled
records directly to order-events, then asserts the in-memory hook fires
exactly once. ~3 s.
[INFO] Tests run: 1 in F7_NotificationDedupTest Time: 3.2 s PASS
docker compose up; POST 5 orders; manually inject a duplicate
OrderFulfilled for one of them via kafka-console-producer:
$ docker exec -i ops-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic order-events <<< \
'{"orderId":"00778798-…","customerId":"replay","fulfilledAt":"2026-04-29T12:00:00Z"}'
$ docker logs ops-notifications | grep -E '(Notification sent|duplicate notification)'
… Notification sent for order=00778798-… customer=v23-1 partition=2 offset=0
… Notification sent for order=5b46cfd3-… customer=v23-3 partition=1 offset=0
… Notification sent for order=b49a0cc4-… customer=v23-2 partition=0 offset=0
… Notification sent for order=eaf81015-… customer=v23-5 partition=0 offset=1
… Notification sent for order=e5dcd42b-… customer=v23-4 partition=0 offset=2
… duplicate notification suppressed partition=1 offset=1 ← injected duplicate
$ docker exec ops-postgres psql -U orders -d orders -t -c \
'SELECT count(*) FROM processed_notifications;'
5 ← still 5; duplicate absorbed
V3.0.0 introduces the saga state machine. Payment is the first step; if it
fails, no prior steps need compensation, the saga goes directly to FAILED,
and processed_orders is inserted for idempotency but no outbox row is
written.
F8_PaymentFailureNoCompensationTest runs in ~0.1 s — pure orchestrator + DB,
no Kafka.
sagaState = FAILED
failure_step = payment
payment.executionCount = 1
payment.compensationCount = 0 ← no compensation needed
inventory.executionCount = 0 ← never reached
Compensation runs in reverse order: payment.compensate() refunds before
the saga transitions to FAILED.
sagaState = FAILED
failure_step = inventory
payment.executionCount = 1
payment.compensationCount = 1 ← refunded
inventory.executionCount = 1 ← attempted
inventory.compensationCount = 0
shipping.executionCount = 0 ← never reached
Both prior steps compensated, in reverse order (inventory first, then payment).
shipping.compensationCount = 0 ← shipping never succeeded
inventory.compensationCount = 1 ← released first
payment.compensationCount = 1 ← refunded last
The contract under test is state-aware re-entry: every state transition is persisted before the next step runs, so on redelivery the orchestrator resumes at the next pending step without re-executing completed work.
The test wraps InventoryStep with one that throws RuntimeException on
first call (simulating a JVM crash). After the first run propagates the
exception out, the saga row sits at INVENTORY_PENDING (the
PAYMENT_PENDING → INVENTORY_PENDING transition was already committed). A
second SagaOrchestrator instance over the same SagaStore runs against
the same in-memory step state — it finds the existing saga row, skips the
payment block, runs inventory + shipping, reaches COMPLETED.
After two invocations:
payment.executionCount = 1 ← NOT re-executed on resume
inventory.executionCount = 1 ← ran on resume only
shipping.executionCount = 1 ← ran on resume only
payment.compensationCount = 0 ← saga succeeded; no compensation
$ docker compose up -d --build
$ docker compose stop fulfillment-service-2
$ docker compose run -d --rm \
-e INVENTORY_FAILURE_RATE=1.0 \
--name ops-fulfillment-failing fulfillment-service-1
$ for i in 1 2; do curl -s -X POST localhost:6080/orders … ; done
$ docker exec ops-postgres psql -U orders -d orders -c \
"SELECT order_id, state, failure_step, failure_reason FROM sagas WHERE state='FAILED';"
order_id | state | failure_step | failure_reason
--------------------------------------+--------+--------------+----------------------------------
221f60d2-006b-419b-9d6d-903bf5a7f6fc | FAILED | inventory | inventory unavailable (simulated)
4949ddc7-a1ee-433f-a098-8ad253a3b0ef | FAILED | inventory | inventory unavailable (simulated)
$ docker logs ops-fulfillment-failing | grep 'payment refunded'
… payment refunded orderId=221f60d2-…
… payment refunded orderId=4949ddc7-…
$ docker exec ops-kafka /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server localhost:9092 --topic order-events \
| awk -F: '{sum+=$3} END {print sum}'
3 ← unchanged from happy-path baseline; failed sagas emit no event
| # | Result | Mechanism | Test |
|---|---|---|---|
| F1 | 1 event for duplicate sends | processed_orders + Idempotency-Key (v2.0.0) |
F1_DuplicateOrdersTest |
| F2 | 1 event after redelivery | commitSync after DB tx + idempotency (v2.0.0) |
F2_DuplicateNotificationsTest |
| F3 | record never lost | DB tx is the durability gate (v2.0.0) | F3_LostOrdersTest |
| F4 | poison → DLQ + consumer survives | byte[] deserialize + DLQ routing (v2.0.0) |
F4_PoisonMessageTest |
| F5 | lag 500 → 0 / 2s window | virtual-thread parallel batch + async outbox (v2.1.0) | F5_ConsumerLagTest |
| F6 | exactly N events for N orders, 2 relays | SELECT FOR UPDATE SKIP LOCKED (v2.2.0) |
F6_MultiInstanceOutboxRaceTest |
| F7 | 1 notification per orderId | processed_notifications (v2.3.0) |
F7_NotificationDedupTest |
| F8 | saga FAILED, no compensation |
saga state machine (v3.0.0) | F8_PaymentFailureNoCompensationTest |
| F9 | inventory failure → payment refunded | reverse-order compensation (v3.0.0) | F9_InventoryFailureCompensatesPaymentTest |
| F10 | shipping failure → both prior compensated | reverse-order compensation (v3.0.0) | F10_ShippingFailureCompensatesAllTest |
| F11 | mid-saga crash → resume at next step | state-aware re-entry (v3.0.0) | F11_SagaResumeAfterCrashTest |
V3 chaos suite total runtime: ~30 s (Testcontainers spin-up + 11 tests).
- No retry-before-DLQ (v2.4.0+ if needed). First parse failure routes straight to DLQ. Fine for malformed JSON (deterministic — retries won't help). Originally targeted for v2.3.0 but deferred: there's no concrete transient-error path in the current pipeline that retry-topics would mitigate. Will revisit when an external HTTP call or similar flaky dependency lands.