Apache Kafka transport
Last updated:
com.babelqueue:babelqueue-kafka is an Apache Kafka transport on the Java core. It sends the
canonical envelope as the record value with the
§6 header projection, and consumes by routing
each record to a handler by URN — so a record it produces is consumed by any other BabelQueue
SDK, and vice-versa. Kafka has no native ack/delay/DLQ/delivery-counter, so the binding
absorbs all four in the transport.
Install
Maven:
<dependency>
<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-kafka</artifactId>
<version>1.2.1</version>
</dependency>
Requirements: Java 17+. It pulls babelqueue-core and org.apache.kafka:kafka-clients
transitively. You supply the Kafka Producer / Consumer.
Produce
import com.babelqueue.kafka.KafkaPublisher;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.ByteArraySerializer;
import java.util.Map;
Map<String, Object> cfg = Map.of(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092",
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class);
try (Producer<byte[], byte[]> producer = new KafkaProducer<>(cfg)) {
String id = KafkaPublisher.create(producer, "orders")
.publish("urn:babel:orders:created", Map.of("order_id", 1042L));
}
publish(urn, data) returns the message meta.id; overloads add a traceId and a relative
Duration delay (delays require a RetryTopics topology, else they raise).
Consume
import com.babelqueue.kafka.KafkaConsumer; // the BabelQueue worker (not kafka-clients' KafkaConsumer)
import com.babelqueue.kafka.RetryTopics;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.ByteArrayDeserializer;
import org.apache.kafka.common.serialization.ByteArraySerializer;
import java.time.Duration;
import java.util.List;
import java.util.Map;
Consumer<byte[], byte[]> consumer = new org.apache.kafka.clients.consumer.KafkaConsumer<>(Map.of(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092",
ConsumerConfig.GROUP_ID_CONFIG, "orders-workers",
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false, // manual commit (process-then-commit)
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class));
consumer.subscribe(List.of("orders"));
// The retry / DLQ producer — the worker republishes failed envelopes through it.
Producer<byte[], byte[]> producer = new KafkaProducer<>(Map.of(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092",
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class));
RetryTopics retry = RetryTopics.forTopic("orders")
.tier(Duration.ofSeconds(5)).tier(Duration.ofMinutes(1)).build();
KafkaConsumer worker = KafkaConsumer.builder(consumer)
.producer(producer)
.retryTopics(retry)
.maxTries(3)
.handler("urn:babel:orders:created", (envelope, record) -> {
// envelope.data(), envelope.traceId(), envelope.attempts() ...
})
.build();
worker.run(() -> true); // poll → process → commit
Both kafka-clients and BabelQueue ship a class called KafkaConsumer, so import only one of
them and fully qualify the other, as above.
A throwing handler republishes the envelope to the next <topic>.retry.<n> tier with
bq-attempts + 1, then commits; once maxTries is reached it goes to <topic>.dlq with a
dead_letter block. The consumer routes on the bq-job header. Unknown-URN strategy is one
of fail / delete / release / dead_letter.
A record is never lost to a failed write. Every retry-tier republish and every <topic>.dlq
write (a handler that has run out of tries, an unknown URN, a poison body) is awaited until the broker confirms it, and only then is the offset committed. If the
write fails, the offset is not committed: the consumer seeks the partition back to that
record, reports the error to onError, and skips the rest of the batch, so the record is
polled again and the handler may run again. Keep handlers idempotent.
The retry-tier republish and every <topic>.dlq write (the annotated envelope, the wrapped poison
record and the raw schema-version rejection) carry the consumed record’s key unchanged: a null key stays null, an empty key stays
empty. The key has no contract meaning and the transport never makes one up. Partition
equality across SDKs is not guaranteed, because client partitioners differ.
onError never blocks settlement. The consumer settles a failing record first (the retry
republish or the <topic>.dlq write, then the commit) and calls onError afterwards, even when
settling itself failed. An exception thrown by the callback is swallowed, so it cannot skip the
DLQ write or the commit, and the poll loop keeps running. Handle errors inside the callback
rather than rethrowing from it.
An undecodable (poison) body is never retried and never reaches a handler. It is dead-lettered
wrapped, like the Go reference (§6.5): a body that is exactly one JSON object keeps its keys,
anything else (not JSON, a scalar or array, trailing tokens, an empty value) becomes
{"raw": "<body>"} (invalid UTF-8 is stored with U+FFFD substitutions), and a dead_letter block
with reason: "poison", error, exception, original_queue, attempts (from the
bq-attempts header) and lang: "java" is attached. The key is kept, and the offset is committed
only after the DLQ write is confirmed (otherwise the partition is rewound).
A body is poison when, and only when, the Go reference’s typed decode would reject it: malformed
JSON (leading zeros, NaN, trailing tokens, a BOM, a second object) or a non-object value;
data, meta or dead_letter not an object; job, trace_id or a string member of meta /
dead_letter not a string; meta.schema_version, meta.created_at, attempts or
dead_letter.failed_at|attempts not an integer that fits int64 ("2026", a fraction or 1e2 is
poison); or a number in data that is not a finite double (1e400, carried verbatim under raw).
Member names are matched exactly as spelled (JOB or Meta is an unknown member, kept as it is)
and every duplicate key is type-checked. A bare JSON null is not poison (an empty envelope on the
unknown-URN path); a well-typed but non-conformant envelope (a missing data, a blank trace_id,
a schema_version other than 1) is routed by its URN like any other record. No decode failure can
wedge a partition: any RuntimeException or StackOverflowError from the scan or the codec is
poison too. The nesting depth the recursive scanner carries is bounded by the thread’s stack
(roughly 3000 to 5000 levels, depending on its size), where Go allows 10000; deeper bodies are
poison. On a poison record dead_letter.attempts is the bq-attempts header (Go writes 0) and
original_queue is the bq-original-topic header when present.
Before decoding, the consumer version-gates on the bq-schema-version record header (§6.7). A
missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF, VT, FF, CR)
changes nothing: the body is decoded as usual. A header present with a null value counts as
missing too. Any other value is compared with "1" exactly
as sent, with no trimming, so "2", "x", "01" and " 1" are all rejected without decoding
the body: the raw record, with its original headers, is written to <topic>.dlq and the offset
is then committed. If the DLQ write fails, the offset is not committed: the consumer seeks back
to the record so it is polled again. With no DLQ configured the record is committed and
dropped. onError is called with a null envelope. A "1" value does not skip the body
check: the decoded envelope still goes through the schema_version validation.
DLQ redrive
KafkaRedriveTransport adapts a Kafka Consumer subscribed to the DLQ topic (auto-commit off)
and a Producer to the core’s Redrive.Transport, so Redrive.redrive (ADR-0026) can replay
<topic>.dlq back to its work topic:
var transport = new KafkaRedriveTransport(dlqConsumer, producer);
Redrive.Result result = Redrive.redrive(transport, "orders.dlq", Redrive.Options.all());
The DLQ record’s key is kept byte for byte on the write back to the work topic and on every
restore write back to the DLQ (dry run, unselected, undecodable, failed redrive write): a null
key stays null, an empty key stays empty. A restore re-writes the DLQ record verbatim, with its
original value bytes, headers and key, so a record that is not valid UTF-8 is never altered, and
stamps a fresh timestamp. The DLQ offset is committed only after the republish is acknowledged.
The transport does not propagate the bq-replay-bypass header.
Kafka commits are cumulative, so a failed run must not leave the group past an unsettled record.
KafkaRedriveTransport therefore implements Redrive.Rewinder: whenever Redrive.redrive stops
on a failed message (a failed restore write, a failed redrive write, a throwing select, a failed
ack), the consumer seeks back to the first unacknowledged record of every partition it handed out
in the batch (Redrive.redrive pops the whole batch before processing it). A failing seek does
not stop the others and is reported together with the original error. An acknowledged record is
never rewound: after a failed redrive write whose restore succeeded, only the rest of the batch is.
Contract mapping (§6)
| Envelope | Apache Kafka |
|---|---|
| body | record value (byte-identical across SDKs) |
job (URN) |
header bq-job (consumer routes on this) |
trace_id |
header bq-trace-id |
meta.id |
header bq-message-id |
meta.schema_version |
header bq-schema-version (set on publish; the consumer checks it before decoding the body, and also validates the body’s meta.schema_version after decoding) |
meta.lang |
header bq-source-lang |
meta.created_at |
record timestamp (Unix ms) |
attempts |
header bq-attempts (authoritative; body is the fallback) |
| reserve / ack | poll → process → commit offset (manual) |
| retry / delay | republish to <topic>.retry.<n> (bq-attempts + 1) |
| dead-letter | <topic>.dlq + dead_letter block |
The Kafka Consumer is mocked with Mockito and the producer with the official MockProducer
— no Kafka, no network. The envelope is unchanged (schema_version stays 1); Apache Kafka
is purely additive.