Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ The envelope wire format is versioned separately by `meta.schema_version`

## [Unreleased]

## [1.4.0] - 2026-11-03

### Added

- **Dead-letter observer (ADR-0039 section 5.1).** `KafkaConsumer.Builder#deadLetterObserver(DeadLetterObserver)` takes the core's optional `com.babelqueue.DeadLetterObserver` (needs a `babelqueue-core` that ships it). It is called once per record the SDK wrote to the DLQ topic, after the broker confirmed the write: an exhausted retry, an unmapped URN under `dead_letter`, a poison body, and a raw record rejected by the schema-version gate (empty `reason`). `DeadLetterEvent.queue` is `meta.queue` when the envelope carries one, else the topic the record came from. It is not called when the write fails, for a retry-tier republish, or when no DLQ is configured; an observer failure is logged and never changes the settle outcome. Without an observer nothing changes.
- **W3C `traceparent` out-of-band propagation (ADR-0028).** `KafkaPublisher`: `publishWithHeaders(Envelope, Map<String,String>)` (the produce-side seam for the core's `com.babelqueue.otel.HeaderSender`) and a `publish(..., Map<String,String> headers)` overload carry out-of-band headers (e.g. `traceparent`, `tracestate`) as UTF-8 Kafka record headers beside the contract `bq-` entries. A contract entry always wins a collision; blank keys/values are skipped; with no/empty headers the message is identical to a plain `publish`. The envelope body is untouched (GR-1, `schema_version` stays 1).
- `BabelHandler.headersOf(ConsumerRecord<byte[], byte[]>)` surfaces the delivered UTF-8 Kafka record headers as a `Map<String,String>` — the `Supplier` source for `Tracing.wrapHandler(tracer, handler, headers)`, so the consumer span becomes a true child of the producer span. No OpenTelemetry dependency is added (the core's OTel API stays optional).

## [1.3.1] - 2026-10-26

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ counter — this transport absorbs all four in the binding layer (the envelope s
<dependency>
<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-kafka</artifactId>
<version>1.3.1</version>
<version>1.4.0</version>
</dependency>
```

Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-kafka</artifactId>
<version>1.3.1</version>
<version>1.4.0</version>
<packaging>jar</packaging>

<name>BabelQueue Apache Kafka</name>
Expand Down Expand Up @@ -50,7 +50,7 @@
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<babelqueue-core.version>1.14.0</babelqueue-core.version>
<babelqueue-core.version>1.15.0</babelqueue-core.version>
<kafka.version>3.9.2</kafka.version>
<junit.version>5.10.3</junit.version>
<mockito.version>5.12.0</mockito.version>
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/com/babelqueue/kafka/BabelHandler.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
package com.babelqueue.kafka;

import com.babelqueue.Envelope;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerRecord;

/**
* Processes one decoded, validated envelope and the raw Kafka record it arrived on. Returning
* normally acknowledges it (the consumer commits the offset past it); throwing routes it to a
* retry topic with {@code bq-attempts + 1} (or to the DLQ once max-tries is reached) — Kafka's
* analogue of "republish then ack".
*
* <p>The raw record carries the out-of-band transport headers (ADR-0028). {@link #headersOf} surfaces them
* as a plain map, ready for the optional OpenTelemetry wrapper so the consumer span becomes a true child of
* the producer span:
*
* <pre>{@code
* KafkaConsumer.builder(consumer)
* .handler(urn, (env, record) -> Tracing
* .wrapHandler(tracer, h, () -> BabelHandler.headersOf(record))
* .handle(env))
* .build();
* }</pre>
*/
@FunctionalInterface
public interface BabelHandler {
void handle(Envelope envelope, ConsumerRecord<byte[], byte[]> record) throws Exception;

/**
* The record's headers as a {@code Map<String, String>} (UTF-8, blank values dropped) — the
* delivered-headers {@code Supplier} source for {@code com.babelqueue.otel.Tracing#wrapHandler}.
* Contains the contract {@code bq-} headers and any out-of-band header (e.g. {@code traceparent},
* {@code tracestate}). Empty when {@code record} is {@code null}. Needs no OpenTelemetry dependency.
*/
static Map<String, String> headersOf(ConsumerRecord<byte[], byte[]> record) {
return record == null ? Map.of() : KafkaHeaders.toMap(record.headers());
}
}
35 changes: 35 additions & 0 deletions src/main/java/com/babelqueue/kafka/KafkaConsumer.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.babelqueue.kafka;

import com.babelqueue.BabelQueueException;
import com.babelqueue.DeadLetterEvent;
import com.babelqueue.DeadLetterObserver;
import com.babelqueue.DeadLetters;
import com.babelqueue.Envelope;
import com.babelqueue.EnvelopeCodec;
Expand Down Expand Up @@ -62,6 +64,11 @@
* the failure is reported to {@code onError}; the record is redelivered on the next poll and rejected again,
* never lost and never handled. An absent or blank header changes nothing.
*
* <p>An optional {@link DeadLetterObserver} (ADR-0039 section 5.1) is called once per record the SDK itself
* wrote to the DLQ topic — exhausted retry, unmapped URN under {@code dead_letter}, poison body, raw version-gated
* body — after the broker confirmed the write. It is not called when the write fails, when a record is only
* republished to a retry tier, or when no DLQ is configured; an observer failure never changes the settle outcome.
*
* <p>Not thread-safe — a Kafka consumer is single-threaded by design; run one per thread.
*/
public final class KafkaConsumer {
Expand All @@ -83,6 +90,7 @@ public interface ErrorHandler {
private final String unknownUrn;
private final ErrorHandler onError;
private final Duration pollTimeout;
private final DeadLetterObserver deadLetterObserver;

private KafkaConsumer(Builder builder) {
this.consumer = builder.consumer;
Expand All @@ -93,6 +101,7 @@ private KafkaConsumer(Builder builder) {
this.unknownUrn = builder.unknownUrn;
this.onError = builder.onError;
this.pollTimeout = builder.pollTimeout;
this.deadLetterObserver = builder.deadLetterObserver;
}

public static Builder builder(Consumer<byte[], byte[]> consumer) {
Expand Down Expand Up @@ -332,6 +341,7 @@ envelope, reason, originalTopic(record), envelope.attempts(),
List<Header> headers = KafkaHeaders.of(annotated);
headers.add(KafkaHeaders.header(KafkaHeaders.ORIGINAL_TOPIC, originalTopic(record)));
send(dlq, record.key(), annotated, headers, System.currentTimeMillis());
notifyDeadLetter(record, envelope, dlq, reason);
}

/**
Expand All @@ -347,6 +357,7 @@ private void deadLetterRawConfirmed(ConsumerRecord<byte[], byte[]> record) throw
requireProducer();
producer.send(new ProducerRecord<>(dlq, null, System.currentTimeMillis(), record.key(),
record.value(), record.headers())).get();
notifyDeadLetter(record, null, dlq, "");
}

/**
Expand Down Expand Up @@ -379,6 +390,7 @@ private void deadLetterPoison(ConsumerRecord<byte[], byte[]> record, Throwable c
headers.add(KafkaHeaders.header(KafkaHeaders.ORIGINAL_TOPIC, originalTopic(record)));
producer.send(new ProducerRecord<>(dlq, null, System.currentTimeMillis(), record.key(),
json.getBytes(StandardCharsets.UTF_8), headers)).get();
notifyDeadLetter(record, null, dlq, "poison");
}

/**
Expand All @@ -395,6 +407,19 @@ private void send(String topic, byte[] key, Envelope envelope, List<Header> head

// -- helpers ----------------------------------------------------------------------------

/**
* Reports a DLQ write the broker just confirmed (ADR-0039 section 5.1). {@code queue} is {@code meta.queue} when the
* envelope carries one, else the topic the record was consumed from. Never throws on an observer failure.
*/
private void notifyDeadLetter(ConsumerRecord<byte[], byte[]> record, Envelope envelope, String dlq, String reason) {
if (deadLetterObserver == null) {
return;
}
String queue = envelope != null && envelope.meta() != null
&& !EnvelopeCodec.isBlankAscii(envelope.meta().queue()) ? envelope.meta().queue() : originalTopic(record);
DeadLetterObserver.notifySafely(deadLetterObserver, new DeadLetterEvent(queue, dlq, reason));
}

private void commit(ConsumerRecord<byte[], byte[]> record) {
TopicPartition tp = new TopicPartition(record.topic(), record.partition());
consumer.commitSync(Map.of(tp, new OffsetAndMetadata(record.offset() + 1)));
Expand Down Expand Up @@ -466,6 +491,7 @@ public static final class Builder {
private String unknownUrn = UnknownUrnStrategy.FAIL;
private ErrorHandler onError;
private Duration pollTimeout = Duration.ofMillis(1000);
private DeadLetterObserver deadLetterObserver;

private Builder(Consumer<byte[], byte[]> consumer) {
this.consumer = Objects.requireNonNull(consumer, "consumer");
Expand Down Expand Up @@ -516,6 +542,15 @@ public Builder pollTimeout(Duration pollTimeout) {
return this;
}

/**
* Optional observer called once per record written to the DLQ topic, after the write is confirmed
* (ADR-0039 section 5.1). Failures of the observer are logged and never change the settle outcome.
*/
public Builder deadLetterObserver(DeadLetterObserver deadLetterObserver) {
this.deadLetterObserver = deadLetterObserver;
return this;
}

public KafkaConsumer build() {
return new KafkaConsumer(this);
}
Expand Down
56 changes: 55 additions & 1 deletion src/main/java/com/babelqueue/kafka/KafkaHeaders.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import com.babelqueue.Envelope;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeader;
Expand Down Expand Up @@ -48,6 +50,58 @@ static Header header(String key, String value) {
return new RecordHeader(key, value.getBytes(StandardCharsets.UTF_8));
}

/**
* Appends the out-of-band {@code extra} headers (e.g. {@code traceparent}, ADR-0028) after the contract
* headers already in {@code headers}. Blank keys/values are skipped, a key already present always wins
* (the contract is never clobbered), and keys are added in sorted order so the result is deterministic.
* A {@code null}/empty map leaves {@code headers} untouched.
*/
static void addOutOfBand(List<Header> headers, Map<String, String> extra) {
if (extra == null || extra.isEmpty()) {
return;
}
List<String> keys = new ArrayList<>(extra.keySet());
keys.removeIf(k -> k == null || k.isEmpty());
keys.sort(String::compareTo);
for (String key : keys) {
String value = extra.get(key);
if (value == null || value.isEmpty() || containsKey(headers, key)) {
continue;
}
headers.add(header(key, value));
}
}

private static boolean containsKey(List<Header> headers, String key) {
for (Header h : headers) {
if (h.key().equals(key)) {
return true;
}
}
return false;
}

/**
* A delivered record's headers as a flat {@code Map<String, String>} (UTF-8, last value wins per key,
* blank values dropped) — the consume-side counterpart of {@link #addOutOfBand}. Both the contract
* {@code bq-} headers and out-of-band ones (e.g. {@code traceparent}) surface; the consumer picks the keys
* it needs. An empty map when the record is {@code null} or carries no headers.
*/
static Map<String, String> toMap(Headers headers) {
Map<String, String> out = new LinkedHashMap<>();
if (headers == null) {
return out;
}
for (Header h : headers) {
// A Kafka header value may be null (protocol length -1): skip it. Iteration order makes the last value win.
if (h.value() == null || h.value().length == 0) {
continue;
}
out.put(h.key(), new String(h.value(), StandardCharsets.UTF_8));
}
return out;
}

private static void put(List<Header> headers, String key, String value) {
if (value != null && !value.isEmpty()) {
headers.add(header(key, value));
Expand All @@ -57,7 +111,7 @@ private static void put(List<Header> headers, String key, String value) {
/** The last header value for {@code key} as a UTF-8 string, or {@code null} if absent. */
static String string(Headers headers, String key) {
Header h = headers.lastHeader(key);
return h == null ? null : new String(h.value(), StandardCharsets.UTF_8);
return h == null || h.value() == null ? null : new String(h.value(), StandardCharsets.UTF_8);
}

/** The header value for {@code key} as an int, or {@code fallback} if absent / unparseable. */
Expand Down
35 changes: 32 additions & 3 deletions src/main/java/com/babelqueue/kafka/KafkaPublisher.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
* topology ({@link #create(Producer, RetryTopics)}) and is routed to the matching
* {@code <topic>.retry.<n>} tier; on a plain publisher a delay raises {@link BabelQueueException}
* rather than being silently dropped (§6.4).
*
* <p>Out-of-band transport headers (e.g. the W3C {@code traceparent}, ADR-0028) ride as extra UTF-8
* record headers <b>beside</b> the contract {@code bq-} headers — never inside the envelope (GR-1);
* see {@link #publishWithHeaders(Envelope, Map)}, the seam for {@code com.babelqueue.otel.HeaderSender}.
*/
public final class KafkaPublisher {

Expand Down Expand Up @@ -63,28 +67,53 @@ public String publish(String urn, Map<String, Object> data, String traceId) {
* set); on a plain publisher a positive delay raises {@link BabelQueueException}.
*/
public String publish(String urn, Map<String, Object> data, String traceId, Duration delay) {
return publish(urn, data, traceId, delay, null);
}

/**
* Publish as {@link #publish(String, Map, String, Duration)}, additionally carrying the
* out-of-band {@code headers} (e.g. {@code traceparent}) as record headers beside the contract
* {@code bq-} headers. A contract header already present always wins a collision; blank keys or
* values are skipped; {@code null}/empty {@code headers} is byte-identical to the plain overload.
*/
public String publish(String urn, Map<String, Object> data, String traceId, Duration delay,
Map<String, String> headers) {
Envelope envelope = EnvelopeCodec.make(urn, data, workTopic, traceId);
if (delay != null && !delay.isZero() && !delay.isNegative()) {
if (retryTopics == null) {
throw new BabelQueueException(
"Kafka has no native delayed delivery; a delay requires retry topics (none configured).");
}
RetryTopics.Tier tier = retryTopics.tierForDelay(delay);
send(tier.topic(), envelope, delay, workTopic);
send(tier.topic(), envelope, delay, workTopic, headers);
} else {
send(workTopic, envelope, null, null);
send(workTopic, envelope, null, null, headers);
}
return envelope.meta() != null ? envelope.meta().id() : "";
}

private void send(String topic, Envelope envelope, Duration delay, String originalTopic) {
/**
* Publish an already-built {@code envelope} to the work topic together with out-of-band transport
* {@code headers} (e.g. a W3C {@code traceparent}, ADR-0028). This is the produce-side seam the
* optional {@code com.babelqueue.otel.HeaderSender} wires to:
* {@code Tracing.publish(tracer, urn, data, queue, publisher::publishWithHeaders)}. The record
* value stays the byte-identical envelope JSON. Returns {@code meta.id}.
*/
public String publishWithHeaders(Envelope envelope, Map<String, String> headers) {
send(workTopic, envelope, null, null, headers);
return envelope.meta() != null ? envelope.meta().id() : "";
}

private void send(String topic, Envelope envelope, Duration delay, String originalTopic,
Map<String, String> outOfBand) {
List<Header> headers = KafkaHeaders.of(envelope);
if (delay != null) {
headers.add(KafkaHeaders.header(KafkaHeaders.DELAY, Long.toString(delay.toMillis())));
}
if (originalTopic != null) {
headers.add(KafkaHeaders.header(KafkaHeaders.ORIGINAL_TOPIC, originalTopic));
}
KafkaHeaders.addOutOfBand(headers, outOfBand);
byte[] value = EnvelopeCodec.encode(envelope).getBytes(StandardCharsets.UTF_8);
long timestamp = envelope.meta() != null ? envelope.meta().createdAt() : System.currentTimeMillis();
producer.send(new ProducerRecord<>(topic, null, timestamp, null, value, headers));
Expand Down
Loading
Loading