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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ The envelope wire format is versioned separately by `meta.schema_version`

## [Unreleased]

## [1.5.0] - 2026-11-03

### Added

- `SqsConsumer.Builder.deadLetterObserver(DeadLetterObserver)` (ADR-0039 section 5.1, opt-in): the observer is called
once for every record this consumer **sends** to the configured `deadLetterQueueUrl` - a version-gated raw
body (empty `reason`), a poison body (`poison`) and an unknown URN under `dead_letter` (`unknown_urn`) - after the
send is confirmed and before the source message is deleted. The event carries the source queue (the
envelope's `meta.queue` for an unknown URN, else the consumer's queue name; a poison or version-gated delivery
always reports the consumer's queue), the DLQ queue name and the `dead_letter.reason`. The call is at-least-once:
if the later `DeleteMessage` fails, the message is redelivered and sent again, and the observer is called again
(a FIFO DLQ deduplicates the record, not the call). It is not called for a failed send, for a message left to the queue's
native `RedrivePolicy` (no DLQ queue, or retries exhausted - the SDK does not dead-letter those), or for a
`delete`/degraded drop. An observer that throws is logged and swallowed; the delete, release and DLQ outcome are
unchanged. Unset (the default) behaviour is byte-for-byte unchanged. Wire format unchanged.
- Requires a `babelqueue-core` that carries `DeadLetterObserver` / `DeadLetterEvent` (core 1.15.0, the version this release pins).

## [1.4.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 @@ -15,7 +15,7 @@ as the PHP/Laravel, Python, Go, Node and .NET SDKs. Implements
<dependency>
<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-sqs</artifactId>
<version>1.4.1</version>
<version>1.5.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-sqs</artifactId>
<version>1.4.1</version>
<version>1.5.0</version>
<packaging>jar</packaging>

<name>BabelQueue Amazon SQS</name>
Expand Down Expand Up @@ -49,7 +49,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>
<aws-sdk.version>2.25.0</aws-sdk.version>
<opentelemetry.version>1.45.0</opentelemetry.version>
<junit.version>6.1.3</junit.version>
Expand Down
62 changes: 57 additions & 5 deletions src/main/java/com/babelqueue/sqs/SqsConsumer.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.babelqueue.sqs;

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 @@ -112,6 +114,7 @@ public interface UnknownUrnHandler {
private final boolean retryJitterFull;
private final DoubleSupplier random;
private final int unknownUrnReleaseDelaySeconds;
private final DeadLetterObserver deadLetterObserver;

/** The SQS maximum for {@code VisibilityTimeout}: 12 hours, in seconds. */
public static final int MAX_VISIBILITY_TIMEOUT_SECONDS = 43_200;
Expand All @@ -136,6 +139,7 @@ private SqsConsumer(Builder builder) {
this.retryJitterFull = builder.retryJitterFull;
this.random = builder.random != null ? builder.random : () -> ThreadLocalRandom.current().nextDouble();
this.unknownUrnReleaseDelaySeconds = builder.unknownUrnReleaseDelaySeconds;
this.deadLetterObserver = builder.deadLetterObserver;
}

public static Builder builder(SqsClient client, String queueUrl) {
Expand Down Expand Up @@ -231,7 +235,7 @@ private void settleVersionGated(Message message, String declaredVersion) {
// Rule 4: the raw body, byte for byte, then delete. Rule 5: a failed send leaves it unsettled.
String body = message.body() == null ? "" : message.body();
if (sendToDeadLetterQueue(message, body, message.hasMessageAttributes()
? message.messageAttributes() : Map.of(), null)) {
? message.messageAttributes() : Map.of(), null, "", SqsQueues.nameFromUrl(queueUrl))) {
delete(message, null);
}
}
Expand Down Expand Up @@ -260,7 +264,7 @@ record = poisonRecord(body.getBytes(StandardCharsets.UTF_8), SqsQueues.nameFromU
return;
}
if (sendToDeadLetterQueue(message, record, message.hasMessageAttributes()
? message.messageAttributes() : Map.of(), partial)) {
? message.messageAttributes() : Map.of(), partial, "poison", SqsQueues.nameFromUrl(queueUrl))) {
delete(message, partial);
}
}
Expand Down Expand Up @@ -291,9 +295,16 @@ private static int receiveAttempts(Message message) {
* so the caller leaves the message unsettled and SQS redelivers it once the visibility timeout expires. A FIFO
* DLQ ({@code .fifo}) gets the consumer's queue name as the message group and the SQS message id (else a
* digest of the body) as the deduplication id, so a send repeated after a lost delete is not duplicated.
*
* <p>After each confirmed send the optional {@link DeadLetterObserver} is called once (ADR-0039 section 5.1) with
* {@code reason} ({@code ""} for a raw version-gated body) and {@code eventQueue}; a failed send does not call
* it. The observer is isolated through {@link DeadLetterObserver#notifySafely}, so it never changes the settle
* outcome. If the later {@code DeleteMessage} fails the message is redelivered and sent again, so the observer
* is called again (at-least-once; a FIFO DLQ deduplicates the record but not this call).
*/
private boolean sendToDeadLetterQueue(
Message message, String body, Map<String, MessageAttributeValue> attributes, Envelope envelope) {
Message message, String body, Map<String, MessageAttributeValue> attributes, Envelope envelope,
String reason, String eventQueue) {
try {
SendMessageRequest.Builder request = SendMessageRequest.builder()
.queueUrl(deadLetterQueueUrl)
Expand All @@ -308,11 +319,28 @@ private boolean sendToDeadLetterQueue(
: sha256Hex(body));
}
client.sendMessage(request.build());
return true;
} catch (RuntimeException sendError) {
report(sendError, envelope, message);
return false;
}
if (deadLetterObserver != null) {
DeadLetterObserver.notifySafely(deadLetterObserver, new DeadLetterEvent(
eventQueue, SqsQueues.nameFromUrl(deadLetterQueueUrl), reason));
}
return true;
}

/**
* The source queue of a dead-letter event for a decoded, accepted envelope: its {@code meta.queue}, else this
* consumer's queue (ADR-0039 section 5.1). A poison or version-gated delivery has no accepted envelope and
* always reports the consumer's queue.
*/
private String sourceQueue(Envelope envelope) {
if (envelope != null && envelope.meta() != null
&& !EnvelopeCodec.isBlankAscii(envelope.meta().queue())) {
return envelope.meta().queue();
}
return SqsQueues.nameFromUrl(queueUrl);
}

private static String sha256Hex(String value) {
Expand Down Expand Up @@ -380,7 +408,8 @@ private void deadLetterUnknownUrn(Message message, Envelope envelope) {
try {
dead = DeadLetters.annotate(envelope, "unknown_urn", SqsQueues.nameFromUrl(queueUrl));
body = EnvelopeCodec.encode(dead);
if (sendToDeadLetterQueue(message, body, SqsAttributes.project(dead), envelope)) {
if (sendToDeadLetterQueue(message, body, SqsAttributes.project(dead), envelope, "unknown_urn",
sourceQueue(envelope))) {
delete(message, envelope);
}
} catch (RuntimeException error) {
Expand Down Expand Up @@ -510,6 +539,7 @@ public static final class Builder {
private boolean retryJitterFull;
private DoubleSupplier random;
private int unknownUrnReleaseDelaySeconds;
private DeadLetterObserver deadLetterObserver;

private Builder(SqsClient client, String queueUrl) {
this.client = Objects.requireNonNull(client, "client");
Expand Down Expand Up @@ -664,6 +694,28 @@ public Builder unknownUrnReleaseDelaySeconds(int seconds) {
return this;
}

/**
* Optional observer of the dead-letters this consumer itself writes (ADR-0039 section 5.1): called once
* per record <b>sent</b> to the {@link #deadLetterQueueUrl DLQ queue} (a version-gated raw body, a poison
* body, an unknown URN under {@code dead_letter}), after the send is confirmed and before the source message
* is deleted. The event carries the source queue (the envelope's {@code meta.queue}
* for an unknown URN, else this consumer's queue name; a poison or version-gated delivery always reports
* the consumer's queue), the DLQ queue name and the {@code dead_letter.reason} ({@code ""} for a raw
* version-gated body). It is at-least-once: if the later {@code DeleteMessage} fails the message is
* redelivered and sent again, and the observer is called again (a FIFO DLQ deduplicates the record, not
* the call).
*
* <p>It is <b>not</b> called when the send fails, when the message is left to the queue's native
* {@code RedrivePolicy} (no DLQ queue, or retries exhausted), or when it is deleted instead of dead-lettered.
* Anything the observer throws is logged and swallowed; the delete, release and DLQ outcome never change.
* Unset (the default): behaviour is unchanged. {@code com.babelqueue.otel.Metrics#deadLetterObserver}
* increments {@code babelqueue.dlq.messages}.
*/
public Builder deadLetterObserver(DeadLetterObserver observer) {
this.deadLetterObserver = observer;
return this;
}

/** Clamp a delay to the SQS {@code VisibilityTimeout} range, 0–43200 s (Go/Python parity). */
private static int clampDelay(int seconds) {
return Math.max(0, Math.min(seconds, MAX_VISIBILITY_TIMEOUT_SECONDS));
Expand Down
Loading
Loading