Case study

DLQ Revive

Replaying Kafka dead-letter messages next to production without rebalancing live consumers, double-delivering, or letting a transform run arbitrary code.

Role
Creator & Maintainer
Timeline
Apr 2026 — Present
Stack
  • Java 17
  • Spring Boot 3
  • Apache Kafka
  • PostgreSQL
  • Angular
  • JSONata

The problem

When a Kafka consumer can't process a message, the message lands in a dead-letter topic. Getting it back out usually means writing a one-off script: a consumer to find the message, some code to fix the payload, and a producer to send it back. When I asked engineers on Reddit how they handle this, the answer was consistent: everyone writes those scripts, nobody enjoys it, and the scripts run untested against production data.

DLQ Revive replaces the script with a self-hosted tool. You connect it to a cluster, browse the dead-letter topic, fix messages with a declarative transform, preview the result side by side, and replay them to a target topic. Every action is audited.

Constraints

Moving bytes between topics is easy. Doing it next to production without making things worse is the actual problem. Four rules shaped the design:

Architecture

Angular dashboard
  • Connect
  • Browse
  • Transform
  • Audit
Spring Boot API
  • DLQReader
  • JsonataEngine
  • IdempotencyGuard
  • RedriveEngine
  • AuditLogger
DLQ topic
  • assign() + seek()
  • no commits
Target topic
  • acks=all
  • idempotent producer
PostgreSQL
  • redrive_log
  • audit_trail
  • transform_templates
The API is the only component that touches Kafka; the browser never holds broker credentials.

An Angular dashboard talks to a Spring Boot API over REST, with Server-Sent Events streaming replay progress back message by message. The API reads from Kafka with a short-lived, read-only consumer, transforms with JSONata, produces to the target topic, and records every replay and user action in PostgreSQL.

Key decisions

01Read with assign() + seek(), never subscribe()

Subscribing makes a consumer a member of a consumer group, and membership changes trigger rebalances. Instead, the reader assigns itself one partition and seeks straight to the requested offset, so it never joins a group. It also never commits offsets: browsing is strictly read-only, and the consumer is closed in a finally block after every page.

02Idempotency in PostgreSQL, not Kafka transactions

Every source message is identified by (topic, partition, offset). Before producing, the replay engine checks a redrive_log table and skips anything already replayed, and it records the message before sending it. A UNIQUE(topic, partition, offset) constraint is the real guarantee; the lookup is only a fast path. Two concurrent sessions can't both replay the same message.

if (idempotencyGuard.alreadyRedriven(topic, partition, offset)) {
    skipped++;
    continue;
}
// Record first: a restart after this line can't produce the message again
idempotencyGuard.recordRedrive(topic, partition, offset, user);
producer.send(new ProducerRecord<>(targetTopic, key, transformed)).get();

This survives restarts without coordinating a transaction across a database and a Kafka cluster.

03JSONata only for transforms

Message fixes are written as JSONata expressions. JSONata is declarative: it can reshape JSON, but it has no access to the file system, network, environment or processes. Groovy or JavaScript would have been more flexible, and would also have turned the transform box into remote code execution on a server that can reach the Kafka cluster.

JSONataGroovy / JS eval()
System, file or network accessNoneFull
Turing-completeNoYes
Remote code execution riskNoneCritical

04Bound everything, then send synchronously

Pages are capped at 100 messages, a read gives up after three empty polls, and a replay session handles at most 100 messages. The producer runs with acks=all and idempotence enabled, and each send waits for the broker's acknowledgement before the next one starts.

Testing

The reader is tested against a real broker using @EmbeddedKafka: the test publishes messages, reads a page back, and checks that the consumer is closed. The idempotency guard has a test asserting that recording the same (topic, partition, offset) twice is rejected by the database. The replay engine, the JSONata engine, the Kafka connector and each REST controller have their own tests. GitHub Actions runs the backend suite on Java 17 for every push and pull request.

What I'd change next