diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3971527..366805e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: name: Conformance suite in sync runs-on: ubuntu-latest env: - BQ_CONFORMANCE_VERSION: 1.6.0 + BQ_CONFORMANCE_VERSION: 1.8.0 # Integrity pin for fetch.sh (sha256 of conformance/fetch.sh at the pinned tag). BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ddaf7..246deb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.1.0] - 2026-10-26 + +MINOR (ADR-0037): the consumer gains an opt-in BabelQueue DLQ and settles poison bodies and version-gated +deliveries on their first delivery. The envelope is unchanged (`schema_version` stays `1`). + +### Added + +- `AsbConsumer.Builder.deadLetterSender(ServiceBusSenderClient)` (opt-in): a sender bound to the `.dlq` + entity (an ordinary Service Bus queue, §4.5; it must exist and belongs to the caller). "Dead-lettering on" + means this sender is set. + +### Changed (behaviour) + +- **Poison body** (not JSON, not an envelope, no valid identity, or an unsupported `meta.schema_version`): + never handed to a handler and never retried by the SDK. With a DLQ sender it is sent as the ADR-0035 record + (`PoisonRecord`, `reason: "poison"`, `original_queue` = the receiver's entity, the exact body bytes kept) and + `Complete`d **only after** the send succeeded; a failed send `Abandon`s it and is reported. With no DLQ + sender nothing changes: it is abandoned and reported, so `MaxDeliveryCount` moves it to the native + sub-queue. +- **Version-gated delivery** (`bq-schema-version` present, not null, not blank, not `1`): settled on the first + delivery, never decoded, no handler or hook, no retry. With a DLQ sender the **raw body, byte for byte** (no + `dead_letter` block) is sent to `.dlq` and the message `Complete`d after the send (a failed send + `Abandon`s it, so the write is retried on redelivery). With no DLQ sender nothing changes: it is dead-lettered + into the native `$DeadLetterQueue` (reason `unsupported schema version`). This consumer has no unknown-URN + strategy option, so the rule is `fail`; an `onUnknownUrn` hook never affects it. +- Requires `babelqueue-core` 1.13.0 (was 1.11.0) for `PoisonRecord`. +- Vendored the cross-SDK conformance suite v1.8.0 (`version_gate_asb`, `poison_dlq` runners added) and moved + the CI pin to it. + ## [1.0.2] - 2026-10-17 ### Fixed diff --git a/README.md b/README.md index e4303a9..fef84df 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ propagation) as the .NET, Python, Go and Node SDKs. Implements com.babelqueue babelqueue-azure-servicebus - 1.0.2 + 1.1.0 ``` @@ -71,8 +71,23 @@ Service Bus is purely additive. `ApplicationProperties["bq-schema-version"]`. Missing, null or blank (empty / ASCII whitespace only) → normal decode. Integer `1` (any integral width) and the string `"1"` → normal decode. Any other value (`2`, `1.5`, `"2"`, `"x"`, `" 1"`, other types) → the body is **not** decoded, the handler -is not called, `onError` is notified with a `null` envelope, and the message is explicitly -**dead-lettered** (reason `unsupported schema version`) — never abandoned. +is not called, no hook runs, `onError` is notified with a `null` envelope, and the message is settled on its +**first** delivery (ADR-0037): with a `deadLetterSender` (bound to `.dlq`) the body is sent there +**exactly as received** (no `dead_letter` block) and `complete`d after the send succeeded (a failed send +`abandon`s it, so the write is retried); without one it is explicitly **dead-lettered** to the native +sub-queue (reason `unsupported schema version`) — never abandoned. + +**Poison bodies (ADR-0037).** A body that is not a valid envelope (not JSON, no identity, unsupported +`meta.schema_version`) never reaches a handler and is never retried by the SDK. With a `deadLetterSender` it is +sent to `.dlq` as the ADR-0035 record (`reason: "poison"`; the original bytes kept, spliced or under +`raw`) and `complete`d after the send; without one it is `abandon`ed, so `MaxDeliveryCount` moves it to the +native dead-letter sub-queue. + +```java +ServiceBusSenderClient dlq = new ServiceBusClientBuilder() + .connectionString(cs).sender().queueOrTopicName("orders.dlq").buildClient(); +AsbConsumer.builder(receiver).handler(urn, handler).deadLetterSender(dlq).build(); +``` ## Build & test diff --git a/pom.xml b/pom.xml index 1ea418e..ff981bf 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.babelqueue babelqueue-azure-servicebus - 1.0.2 + 1.1.0 jar BabelQueue Azure Service Bus @@ -49,7 +49,7 @@ 17 UTF-8 UTF-8 - 1.11.0 + 1.13.0 7.17.5 5.10.3 5.12.0 diff --git a/src/main/java/com/babelqueue/azureservicebus/AsbConsumer.java b/src/main/java/com/babelqueue/azureservicebus/AsbConsumer.java index e06d9f9..385a4ce 100644 --- a/src/main/java/com/babelqueue/azureservicebus/AsbConsumer.java +++ b/src/main/java/com/babelqueue/azureservicebus/AsbConsumer.java @@ -1,15 +1,21 @@ package com.babelqueue.azureservicebus; +import com.azure.core.util.BinaryData; +import com.azure.messaging.servicebus.ServiceBusMessage; import com.azure.messaging.servicebus.ServiceBusReceivedMessage; import com.azure.messaging.servicebus.ServiceBusReceiverClient; +import com.azure.messaging.servicebus.ServiceBusSenderClient; import com.azure.messaging.servicebus.models.DeadLetterOptions; import com.babelqueue.BabelQueueException; import com.babelqueue.Envelope; import com.babelqueue.EnvelopeCodec; +import com.babelqueue.JsonValues; +import com.babelqueue.PoisonRecord; import com.babelqueue.UnknownUrnException; import java.math.BigDecimal; import java.time.Duration; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.function.BooleanSupplier; @@ -22,17 +28,29 @@ * {@code DeliveryCount - 1} (broker-authoritative on ASB) for the handler. The poll loop never * stops on a bad message — observe via {@code onError}/{@code onUnknownUrn}. * - *

Per §4.7 the {@code bq-schema-version} application property is checked before the body is - * decoded: when it is present and is not the schema version the core supports - * ({@link EnvelopeCodec#SCHEMA_VERSION}), the body is never decoded or routed. Producers write an + *

Version gate (§4.7, ADR-0037 decision 1). The {@code bq-schema-version} application property is checked + * before the body is decoded: when it is present and is not the schema version the core supports + * ({@link EnvelopeCodec#SCHEMA_VERSION}), the body is never decoded or routed, no handler or + * {@code onUnknownUrn} hook runs and nothing is retried, on the first delivery. Producers write an * integer {@code 1}, so any integral numeric type with value {@code 1} and the string {@code "1"} * (compared exactly as sent, no trimming) are accepted; every other number ({@code 2}, {@code 0}, - * {@code 1.5}), string ({@code "2"}, {@code "x"}, {@code "01"}, {@code " 1"}) or type is rejected. - * A rejected message is explicitly dead-lettered (reason {@code unsupported schema version}) — - * never abandoned, which would redeliver it until {@code MaxDeliveryCount} — and {@code onError} is - * notified (with a {@code null} envelope). A missing, {@code null}, or blank string (empty or - * ASCII-whitespace-only: space, tab, LF, VT, FF, CR) property changes nothing, and a supported - * version still goes through the post-decode {@link EnvelopeCodec#accepts} check. + * {@code 1.5}), string ({@code "2"}, {@code "x"}, {@code "01"}, {@code " 1"}) or type is rejected. A missing, + * {@code null}, or blank string (empty or ASCII-whitespace-only: space, tab, LF, VT, FF, CR) property changes + * nothing, and a supported version still goes through the post-decode {@link EnvelopeCodec#accepts} check. This + * consumer has no unknown-URN strategy option, so the configured strategy is {@code fail}; {@code onError} is + * notified (with a {@code null} envelope). With a {@link Builder#deadLetterSender dead-letter sender} the body is + * sent to {@code .dlq} exactly as received (no {@code dead_letter} block) and the message + * {@code Complete}d only after the send succeeded (a failed send {@code Abandon}s it, so the gate rejects the + * redelivery again and the write is retried). With none it is handed to the broker's native mechanism: explicitly + * dead-lettered (reason {@code unsupported schema version}) into {@code $DeadLetterQueue} — never abandoned, + * which would redeliver it until {@code MaxDeliveryCount}. + * + *

Poison (ADR-0037 decision 2). A body that is not a valid envelope (not JSON, no valid identity, an + * unsupported {@code meta.schema_version}) is never handed to a handler and never retried by the SDK. With a + * dead-letter sender the ADR-0035 record ({@link PoisonRecord}, {@code reason: "poison"}, {@code original_queue} = + * the receiver's entity) is sent to it and the message {@code Complete}d only after the send succeeded (a failed + * send {@code Abandon}s it). With none it is {@code Abandon}ed and reported — left to {@code MaxDeliveryCount}, which + * moves it to the native sub-queue. */ public final class AsbConsumer { @@ -62,6 +80,7 @@ public interface UnknownUrnHandler { private final Duration maxWaitTime; private final ErrorHandler onError; private final UnknownUrnHandler onUnknownUrn; + private final ServiceBusSenderClient deadLetterSender; private AsbConsumer(Builder builder) { this.receiver = builder.receiver; @@ -70,6 +89,7 @@ private AsbConsumer(Builder builder) { this.maxWaitTime = builder.maxWaitTime; this.onError = builder.onError; this.onUnknownUrn = builder.onUnknownUrn; + this.deadLetterSender = builder.deadLetterSender; } public static Builder builder(ServiceBusReceiverClient receiver) { @@ -106,21 +126,49 @@ private void handle(ServiceBusReceivedMessage message) { Object declared = message.getApplicationProperties().get(SCHEMA_VERSION_PROPERTY); String description = "Rejected a BabelQueue message with unsupported " + SCHEMA_VERSION_PROPERTY + " property '" + truncateForReport(declared) + "' (supported: " + EnvelopeCodec.SCHEMA_VERSION + "); body not decoded."; - // Settle first: the message is terminal, so a throwing onError must not leave it unsettled. - receiver.deadLetter(message, new DeadLetterOptions() - .setDeadLetterReason(SCHEMA_VERSION_DEAD_LETTER_REASON) - .setDeadLetterErrorDescription(description)); + if (deadLetterSender == null) { + // Native (rule 3): an explicit DeadLetter into $DeadLetterQueue, not an Abandon that would + // redeliver it until MaxDeliveryCount. Settle first: the message is terminal, so a throwing + // onError must not leave it unsettled. + receiver.deadLetter(message, new DeadLetterOptions() + .setDeadLetterReason(SCHEMA_VERSION_DEAD_LETTER_REASON) + .setDeadLetterErrorDescription(description)); + report(new BabelQueueException(description), null, message); + return; + } + // Rule 4: the raw body, byte for byte, to .dlq; Complete only after the send. Rule 5: a failed + // send Abandons it (the gate rejects the redelivery again, so the write is retried). + try { + send(rawMessage(message)); + } catch (RuntimeException sendFailure) { + receiver.abandon(message); + report(new BabelQueueException(description), null, message); + report(sendFailure, null, message); + return; + } + receiver.complete(message); report(new BabelQueueException(description), null, message); return; } - String body = message.getBody() == null ? "" : message.getBody().toString(); + // Strict UTF-8: BinaryData.toString() would turn a bad byte into U+FFFD and let a corrupted body decode as a + // valid envelope. A body that is not valid UTF-8 is poison (ADR-0037 decision 2), never decoded. + String body = strictUtf8(bodyBytes(message)); + if (body == null) { + BabelQueueException cause = new BabelQueueException( + "Rejected a BabelQueue message whose body is not valid UTF-8 from Azure Service Bus."); + Envelope none = EnvelopeCodec.decode(""); + report(cause, none, message); + settlePoison(message, none, cause); + return; + } Envelope envelope = reconcile(EnvelopeCodec.decode(body), message.getDeliveryCount()); if (!EnvelopeCodec.accepts(envelope)) { - report(new BabelQueueException("Rejected a non-conformant BabelQueue envelope from Azure Service Bus."), - envelope, message); - receiver.abandon(message); + BabelQueueException cause = new BabelQueueException( + "Rejected a non-conformant BabelQueue envelope from Azure Service Bus."); + report(cause, envelope, message); + settlePoison(message, envelope, cause); return; } @@ -147,6 +195,91 @@ private void handle(ServiceBusReceivedMessage message) { } } + /** + * Settles a poison body (ADR-0037 decision 2): the ADR-0035 record to the DLQ sender and {@code Complete} after the + * send; a failed send {@code Abandon}s it. With no sender it is {@code Abandon}ed (native: {@code MaxDeliveryCount}). + */ + private void settlePoison(ServiceBusReceivedMessage message, Envelope envelope, Throwable cause) { + if (deadLetterSender == null) { + receiver.abandon(message); + return; + } + try { + String record = poisonRecord(bodyBytes(message), entityName(), (int) Math.max(message.getDeliveryCount() - 1, 0), + cause); + ServiceBusMessage forwarded = new ServiceBusMessage(BinaryData.fromString(record)) + .setContentType("application/json"); + if (message.getMessageId() != null) { + forwarded.setMessageId(message.getMessageId()); + } + send(forwarded); + } catch (RuntimeException sendFailure) { + receiver.abandon(message); + report(sendFailure, envelope, message); + return; + } + receiver.complete(message); + } + + /** + * The ADR-0035 DLQ record of a poison {@code body}: the one builder this consumer writes with (conformance + * {@code poison_dlq} drives it for every body). + */ + static String poisonRecord(byte[] body, String originalQueue, int attempts, Throwable cause) { + Map block = new LinkedHashMap<>(); + block.put("reason", "poison"); + block.put("error", cause.getMessage()); + block.put("exception", cause.getClass().getName()); + block.put("failed_at", System.currentTimeMillis()); + block.put("original_queue", originalQueue); + block.put("attempts", attempts); + block.put("lang", EnvelopeCodec.SOURCE_LANG); + return PoisonRecord.build(body, JsonValues.encode(block)); + } + + /** The bytes as text, or {@code null} when they are not valid UTF-8 (never U+FFFD substitution). */ + private static String strictUtf8(byte[] body) { + try { + return java.nio.charset.StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(body)).toString(); + } catch (java.nio.charset.CharacterCodingException invalid) { + return null; + } + } + + /** The exact bytes of the message body (empty when it has none). */ + private static byte[] bodyBytes(ServiceBusReceivedMessage message) { + return message.getBody() == null ? new byte[0] : message.getBody().toBytes(); + } + + /** The receiver's entity: a queue name, or the topic of a topic subscription. */ + private String entityName() { + String path = receiver.getEntityPath(); + if (path == null) { + return ""; + } + int subscription = path.indexOf("/subscriptions/"); + return subscription >= 0 ? path.substring(0, subscription) : path; + } + + /** A copy of {@code message} for the DLQ: the exact body bytes, content type and message id; nothing re-encoded. */ + private static ServiceBusMessage rawMessage(ServiceBusReceivedMessage message) { + ServiceBusMessage forwarded = new ServiceBusMessage(BinaryData.fromBytes(bodyBytes(message))); + if (message.getContentType() != null) { + forwarded.setContentType(message.getContentType()); + } + if (message.getMessageId() != null) { + forwarded.setMessageId(message.getMessageId()); + } + return forwarded; + } + + private void send(ServiceBusMessage message) { + deadLetterSender.sendMessage(message); + } + /** * Renders a rejected property value for the dead-letter description and the error message: at most the first * {@value #MAX_REPORTED_VALUE_LENGTH} characters, followed by {@code ...} when cut. Service Bus caps the @@ -232,6 +365,7 @@ public static final class Builder { private Duration maxWaitTime; private ErrorHandler onError; private UnknownUrnHandler onUnknownUrn; + private ServiceBusSenderClient deadLetterSender; private Builder(ServiceBusReceiverClient receiver) { this.receiver = Objects.requireNonNull(receiver, "receiver"); @@ -270,6 +404,18 @@ public Builder onUnknownUrn(UnknownUrnHandler handler) { return this; } + /** + * Turns BabelQueue dead-lettering on (opt-in, ADR-0037 decision 2): a sender bound to the {@code .dlq} + * entity (an ordinary Service Bus queue, §4.5; it must exist). A poison body is sent to it as the ADR-0035 + * record and a version-gated delivery as its raw body; the source message is {@code Complete}d only after the + * send succeeded. Unset (the default): a poison body is abandoned and a version-gated delivery is + * dead-lettered into the native {@code $DeadLetterQueue}. The sender belongs to the caller and is not closed. + */ + public Builder deadLetterSender(ServiceBusSenderClient sender) { + this.deadLetterSender = sender; + return this; + } + public AsbConsumer build() { return new AsbConsumer(this); } diff --git a/src/test/java/com/babelqueue/azureservicebus/AsbConformanceTest.java b/src/test/java/com/babelqueue/azureservicebus/AsbConformanceTest.java index 28fc033..f5f4cd5 100644 --- a/src/test/java/com/babelqueue/azureservicebus/AsbConformanceTest.java +++ b/src/test/java/com/babelqueue/azureservicebus/AsbConformanceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -14,6 +15,7 @@ import com.azure.messaging.servicebus.ServiceBusMessage; import com.azure.messaging.servicebus.ServiceBusReceivedMessage; import com.azure.messaging.servicebus.ServiceBusReceiverClient; +import com.azure.messaging.servicebus.ServiceBusSenderClient; import com.azure.messaging.servicebus.models.DeadLetterOptions; import com.babelqueue.Envelope; import com.babelqueue.EnvelopeCodec; @@ -26,11 +28,13 @@ import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; /** * Azure Service Bus binding conformance against the vendored canonical suite's {@code asb} * block: the §4 native projection and the {@code attempts = max(body, DeliveryCount - 1)} - * reconciliation. The final Service Bus types are mocked with Mockito — no Azure, no network. + * reconciliation, plus the ADR-0037 {@code version_gate_asb} settle rule and the ADR-0035 {@code poison_dlq} record + * shape driven through this consumer. The final Service Bus types are mocked with Mockito — no Azure, no network. */ class AsbConformanceTest { @@ -166,4 +170,116 @@ void schemaVersionGateMatchesGolden() throws Exception { } } } + // ---- poison_dlq (ADR-0035 shape, ADR-0037 decision 2) ----------------------------------------- + + @Test + void poisonDlqRecordShapeMatchesGolden() throws Exception { + JSONArray cases = new JSONObject(resource("manifest.json")).getJSONObject("poison_dlq").getJSONArray("cases"); + assertTrue(cases.length() > 0); + for (int i = 0; i < cases.length(); i++) { + JSONObject testCase = cases.getJSONObject(i); + String name = testCase.getString("name"); + ServiceBusReceivedMessage msg = mock(ServiceBusReceivedMessage.class); + // Service Bus carries raw bytes, so every case (invalid UTF-8 included) goes through the consumer. + when(msg.getBody()).thenReturn(BinaryData.fromBytes(PoisonRecordChecks.bodyOf(testCase))); + when(msg.getDeliveryCount()).thenReturn(1L); + ServiceBusReceiverClient receiver = mock(ServiceBusReceiverClient.class); + when(receiver.receiveMessages(anyInt())).thenReturn(new IterableStream<>(List.of(msg))); + when(receiver.getEntityPath()).thenReturn("orders"); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + int[] handled = {0}; + + AsbConsumer.builder(receiver) + .handler(URN, (env, message) -> handled[0]++) + .deadLetterSender(sender) + .build() + .poll(); + + assertEquals(0, handled[0], name); + ArgumentCaptor sent = ArgumentCaptor.forClass(ServiceBusMessage.class); + verify(sender, times(1)).sendMessage(sent.capture()); + verify(receiver, times(1)).complete(msg); + PoisonRecordChecks.check(testCase, sent.getValue().getBody().toString()); + } + } + + // ---- version_gate_asb (ADR-0037 decision 1) --------------------------------------------------- + + @Test + void versionGateSettleRuleMatchesGolden() throws Exception { + JSONObject section = new JSONObject(resource("manifest.json")).getJSONObject("version_gate_asb"); + JSONArray cases = section.getJSONArray("cases"); + JSONArray bodies = section.getJSONArray("incoming_bodies"); + assertEquals(17, cases.length()); + int asserted = 0; + for (int i = 0; i < cases.length(); i++) { + JSONObject testCase = cases.getJSONObject(i); + if (!"fail".equals(testCase.optString("on_unknown_urn", "fail"))) { + // The Java Azure Service Bus consumer has only an onUnknownUrn callback and no strategy option (it + // behaves as `fail`): the suite tells Java, .NET and Node runners to skip these rows, never to pass + // them. + continue; + } + asserted++; + for (int b = 0; b < bodies.length(); b++) { + runVersionGateCase(testCase, bodies.getJSONObject(b).getString("body")); + } + } + assertTrue(asserted > 0); + } + + private static void runVersionGateCase(JSONObject testCase, String body) { + String label = testCase.getString("name"); + boolean deadLetter = "on".equals(testCase.getString("dead_letter")); + JSONObject expect = testCase.getJSONObject("expect"); + + ServiceBusReceivedMessage msg = mock(ServiceBusReceivedMessage.class); + when(msg.getBody()).thenReturn(BinaryData.fromString(body)); + when(msg.getDeliveryCount()).thenReturn(testCase.has("bq_attempts") ? 8L : 1L); + Map props = new HashMap<>(); + props.put("bq-schema-version", 2); + if (testCase.has("bq_attempts")) { + props.put("bq-attempts", testCase.getString("bq_attempts")); + } + when(msg.getApplicationProperties()).thenReturn(props); + ServiceBusReceiverClient receiver = mock(ServiceBusReceiverClient.class); + when(receiver.receiveMessages(anyInt())).thenReturn(new IterableStream<>(List.of(msg))); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + if ("fails".equals(testCase.optString("dlq_write", "ok"))) { + org.mockito.Mockito.doThrow(new IllegalStateException("dlq write failed")).when(sender) + .sendMessage(any(ServiceBusMessage.class)); + } + int[] handled = {0}; + AsbConsumer.Builder builder = AsbConsumer.builder(receiver).handler(URN, (env, message) -> handled[0]++); + if (deadLetter) { + builder.deadLetterSender(sender); + } + builder.build().poll(); + + assertEquals(0, handled[0], label); + // "off" means no DLQ writer exists in this consumer (no sender): outcome_no_writer applies. + String outcome = !deadLetter && expect.has("outcome_no_writer") + ? expect.getString("outcome_no_writer") : expect.getString("outcome"); + switch (outcome) { + case "dlq_raw" -> { + ArgumentCaptor sent = ArgumentCaptor.forClass(ServiceBusMessage.class); + verify(sender, times(1)).sendMessage(sent.capture()); + assertEquals(body, sent.getValue().getBody().toString(), label); + assertTrue(!sent.getValue().getBody().toString().contains("\"dead_letter\""), label); + verify(receiver, times(1)).complete(msg); + verify(receiver, never()).abandon(any(ServiceBusReceivedMessage.class)); + } + case "unsettled" -> { + verify(receiver, times(1)).abandon(msg); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + } + case "native" -> { + // An explicit native DeadLetter (this consumer's choice) or an Abandon: never Complete, never SDK retry. + verify(sender, never()).sendMessage(any(ServiceBusMessage.class)); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + verify(receiver, times(1)).deadLetter(any(ServiceBusReceivedMessage.class), any(DeadLetterOptions.class)); + } + default -> throw new AssertionError("unknown outcome in " + label + ": " + outcome); + } + } } diff --git a/src/test/java/com/babelqueue/azureservicebus/AsbConsumerTest.java b/src/test/java/com/babelqueue/azureservicebus/AsbConsumerTest.java index 557ecad..33afe5d 100644 --- a/src/test/java/com/babelqueue/azureservicebus/AsbConsumerTest.java +++ b/src/test/java/com/babelqueue/azureservicebus/AsbConsumerTest.java @@ -16,8 +16,10 @@ import com.azure.core.util.BinaryData; import com.azure.core.util.IterableStream; +import com.azure.messaging.servicebus.ServiceBusMessage; import com.azure.messaging.servicebus.ServiceBusReceivedMessage; import com.azure.messaging.servicebus.ServiceBusReceiverClient; +import com.azure.messaging.servicebus.ServiceBusSenderClient; import com.azure.messaging.servicebus.models.DeadLetterOptions; import com.babelqueue.BabelQueueException; import com.babelqueue.DeadLetters; @@ -307,4 +309,193 @@ void schemaVersionGateKeepsShortRejectedValueIntact() { assertTrue(options.getValue().getDeadLetterErrorDescription().contains("'" + value + "'")); assertTrue(!options.getValue().getDeadLetterErrorDescription().contains("...")); } + // ---- ADR-0037: poison record, version gate and the DLQ sender -------------------------------- + + private static ServiceBusReceiverClient receiverFor(String entity, ServiceBusReceivedMessage... messages) { + ServiceBusReceiverClient receiver = receiverWith(messages); + when(receiver.getEntityPath()).thenReturn(entity); + return receiver; + } + + private static ServiceBusMessage sentTo(ServiceBusSenderClient sender) { + ArgumentCaptor sent = ArgumentCaptor.forClass(ServiceBusMessage.class); + verify(sender, times(1)).sendMessage(sent.capture()); + return sent.getValue(); + } + + @Test + void poisonBodyWithDlqSenderIsWrittenAsTheAdr0035RecordThenCompleted() { + ServiceBusReceivedMessage message = received(4, "{\"not\":\"an envelope\",\"big\":12345678901234567890}"); + when(message.getMessageId()).thenReturn("m-1"); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + int[] handled = {0}; + + AsbConsumer.builder(receiver).handler(URN, (env, msg) -> handled[0]++).deadLetterSender(sender).build().poll(); + + assertEquals(0, handled[0]); + ServiceBusMessage record = sentTo(sender); + assertEquals("m-1", record.getMessageId()); + assertEquals("application/json", record.getContentType()); + String text = record.getBody().toString(); + // An object body is spliced: its members stay as written (no re-encoding), plus the dead_letter block. + assertTrue(text.contains("\"big\":12345678901234567890"), text); + org.json.JSONObject block = new org.json.JSONObject(text).getJSONObject("dead_letter"); + assertEquals("poison", block.getString("reason")); + assertEquals("orders", block.getString("original_queue")); + assertEquals(3, block.getInt("attempts")); + verify(receiver).complete(message); + verify(receiver, never()).abandon(any(ServiceBusReceivedMessage.class)); + } + + @Test + void poisonBytesThatAreNotUtf8AreBase64WrappedExactly() { + ServiceBusReceivedMessage message = mock(ServiceBusReceivedMessage.class); + when(message.getBody()).thenReturn(BinaryData.fromBytes(new byte[] {(byte) 0xFF, (byte) 0xFE, 'a'})); + when(message.getDeliveryCount()).thenReturn(1L); + ServiceBusReceiverClient receiver = receiverFor("orders/subscriptions/audit", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + + AsbConsumer.builder(receiver).deadLetterSender(sender).build().poll(); + + org.json.JSONObject record = new org.json.JSONObject(sentTo(sender).getBody().toString()); + assertEquals("//5h", record.getString("raw")); + assertEquals("base64", record.getString("raw_encoding")); + // The entity of a topic subscription is the topic. + assertEquals("orders", record.getJSONObject("dead_letter").getString("original_queue")); + verify(receiver).complete(message); + } + + @Test + void validEnvelopeWithOneInvalidUtf8ByteIsPoisonNotHandedToAHandler() { + // Decoded leniently (U+FFFD) this would be a valid envelope; it must be dead-lettered byte for byte instead. + String json = EnvelopeCodec.encode(EnvelopeCodec.make(URN, Map.of("note", "ab"), "orders", null)); + byte[] text = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); + int at = json.indexOf("ab"); + byte[] bad = text.clone(); + bad[at] = (byte) 0xFF; // one invalid byte inside a string value of an otherwise valid envelope + ServiceBusReceivedMessage message = mock(ServiceBusReceivedMessage.class); + when(message.getBody()).thenReturn(BinaryData.fromBytes(bad)); + when(message.getDeliveryCount()).thenReturn(1L); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + int[] handled = {0}; + + AsbConsumer.builder(receiver).handler(URN, (env, msg) -> handled[0]++).deadLetterSender(sender).build().poll(); + + assertEquals(0, handled[0]); + org.json.JSONObject record = new org.json.JSONObject(sentTo(sender).getBody().toString()); + assertEquals("base64", record.getString("raw_encoding")); + assertEquals(java.util.Base64.getEncoder().encodeToString(bad), record.getString("raw")); + assertEquals("poison", record.getJSONObject("dead_letter").getString("reason")); + verify(receiver).complete(message); + } + + @Test + void invalidUtf8BodyWithoutDlqSenderIsAbandoned() { + ServiceBusReceivedMessage message = mock(ServiceBusReceivedMessage.class); + when(message.getBody()).thenReturn(BinaryData.fromBytes(new byte[] {'{', (byte) 0xFF, '}'})); + when(message.getDeliveryCount()).thenReturn(1L); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + int[] handled = {0}; + + AsbConsumer.builder(receiver).handler(URN, (env, msg) -> handled[0]++).build().poll(); + + assertEquals(0, handled[0]); + verify(receiver).abandon(message); + } + + @Test + void poisonFailedDlqSendAbandonsAndReports() { + ServiceBusReceivedMessage message = received(1, "{}"); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + org.mockito.Mockito.doThrow(new IllegalStateException("dlq down")).when(sender) + .sendMessage(any(ServiceBusMessage.class)); + List errors = new ArrayList<>(); + + AsbConsumer.builder(receiver).deadLetterSender(sender).onError((e, env, m) -> errors.add(e)).build().poll(); + + verify(receiver).abandon(message); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + assertEquals(2, errors.size()); + assertEquals("dlq down", errors.get(1).getMessage()); + } + + @Test + void poisonWithoutDlqSenderIsAbandonedLeftToTheNativeMechanism() { + ServiceBusReceivedMessage message = received(1, "{}"); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + + AsbConsumer.builder(receiver).build().poll(); + + verify(receiver).abandon(message); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + verify(receiver, never()).deadLetter(any(ServiceBusReceivedMessage.class), any(DeadLetterOptions.class)); + } + + @Test + void versionGatedWithDlqSenderForwardsTheRawBytesThenCompletes() { + // Deliberately non-canonical: a writer that decodes and re-encodes would change these bytes. + String raw = "{\"meta\": {\"schema_version\": 2}, \"job\": \"" + URN + "\", \"data\": {\"n\": 1.50}}\n"; + ServiceBusReceivedMessage message = withVersion(raw, 2, true); + when(message.getContentType()).thenReturn("application/json"); + when(message.getMessageId()).thenReturn("m-9"); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + int[] handled = {0}; + int[] unknown = {0}; + List errors = new ArrayList<>(); + + AsbConsumer.builder(receiver) + .handler(URN, (env, msg) -> handled[0]++) + .onUnknownUrn((env, msg) -> unknown[0]++) + .onError((e, env, m) -> errors.add(e)) + .deadLetterSender(sender) + .build().poll(); + + assertEquals(0, handled[0]); + assertEquals(0, unknown[0]); + ServiceBusMessage forwarded = sentTo(sender); + assertEquals(raw, forwarded.getBody().toString()); + assertEquals("m-9", forwarded.getMessageId()); + assertEquals("application/json", forwarded.getContentType()); + verify(receiver).complete(message); + verify(receiver, never()).deadLetter(any(ServiceBusReceivedMessage.class), any(DeadLetterOptions.class)); + verify(receiver, never()).abandon(any(ServiceBusReceivedMessage.class)); + assertEquals(1, errors.size()); + } + + @Test + void versionGatedFailedDlqSendAbandonsAndReports() { + ServiceBusReceivedMessage message = withVersion("not json", "2", true); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + ServiceBusSenderClient sender = mock(ServiceBusSenderClient.class); + org.mockito.Mockito.doThrow(new IllegalStateException("dlq down")).when(sender) + .sendMessage(any(ServiceBusMessage.class)); + List errors = new ArrayList<>(); + + AsbConsumer.builder(receiver).deadLetterSender(sender).onError((e, env, m) -> errors.add(e)).build().poll(); + + verify(receiver).abandon(message); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + verify(receiver, never()).deadLetter(any(ServiceBusReceivedMessage.class), any(DeadLetterOptions.class)); + assertEquals(2, errors.size()); + assertTrue(errors.get(0).getMessage().contains("bq-schema-version")); + assertEquals("dlq down", errors.get(1).getMessage()); + } + + @Test + void versionGatedHookOnlyConsumerNeverRunsTheHookAndDeadLettersNatively() { + // A hook never affects a version-gated delivery (ADR-0037, "Configured strategy only"): it must not Complete. + ServiceBusReceivedMessage message = withVersion(envelopeJson(), 2, true); + ServiceBusReceiverClient receiver = receiverFor("orders", message); + int[] unknown = {0}; + + AsbConsumer.builder(receiver).onUnknownUrn((env, msg) -> unknown[0]++).build().poll(); + + assertEquals(0, unknown[0]); + verify(receiver).deadLetter(eq(message), any(DeadLetterOptions.class)); + verify(receiver, never()).complete(any(ServiceBusReceivedMessage.class)); + } } diff --git a/src/test/java/com/babelqueue/azureservicebus/PoisonRecordChecks.java b/src/test/java/com/babelqueue/azureservicebus/PoisonRecordChecks.java new file mode 100644 index 0000000..7f417d5 --- /dev/null +++ b/src/test/java/com/babelqueue/azureservicebus/PoisonRecordChecks.java @@ -0,0 +1,131 @@ +package com.babelqueue.azureservicebus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * The assertions of the core-scope {@code poison_dlq} conformance section (ADR-0035), applied to the DLQ record a + * consumer actually wrote: the {@code dead_letter} block says {@code poison} / {@code orders}, and the rest of the + * record is the wrapper of the case or a byte-level splice of the body. + */ +final class PoisonRecordChecks { + + private PoisonRecordChecks() { + } + + /** The exact bytes of the case's body ({@code body} text or {@code body_base64}). */ + static byte[] bodyOf(JSONObject testCase) { + return testCase.has("body_base64") + ? Base64.getDecoder().decode(testCase.getString("body_base64")) + : testCase.getString("body").getBytes(StandardCharsets.UTF_8); + } + + static void check(JSONObject testCase, String record) { + String name = testCase.getString("name"); + JSONObject expect = testCase.getJSONObject("expect"); + String shape = expect.getString("shape"); + JSONObject want = expect.getJSONObject("record"); + assertFalse(record.indexOf('�') >= 0, name + ": the record holds U+FFFD"); + Set keys = topLevelKeys(record); + boolean wrapper = want.has("raw") && keys.contains("raw"); + + switch (shape) { + case "wrapper" -> assertTrue(wrapper, name + ": expected the wrapper, got " + record); + case "splice" -> { + assertFalse(keys.contains("raw_encoding"), name + ": expected a splice, got " + record); + assertTrue(keys.contains("dead_letter"), name + ": the splice lost dead_letter: " + record); + } + case "splice_or_wrapper" -> { } + default -> throw new AssertionError(name + ": unknown shape " + shape); + } + + if (wrapper) { + JSONObject parsed = new JSONObject(record); + JSONObject deadLetter = (JSONObject) parsed.remove("dead_letter"); + assertEquals("poison", deadLetter.getString("reason"), name); + assertEquals("orders", deadLetter.getString("original_queue"), name); + assertTrue(want.similar(parsed), name + ": " + record); + return; + } + + assertFalse(keys.contains("raw") && keys.contains("raw_encoding"), name + ": a splice must not wrap"); + assertFalse(keys.contains("raw_encoding"), name + ": a splice must not carry raw_encoding"); + if (expect.has("splice_keys")) { + Set expectedKeys = new LinkedHashSet<>(); + JSONArray list = expect.getJSONArray("splice_keys"); + for (int i = 0; i < list.length(); i++) { + expectedKeys.add(list.getString(i)); + } + Set actualKeys = new LinkedHashSet<>(keys); + actualKeys.remove("dead_letter"); + assertEquals(expectedKeys, actualKeys, name + ": top-level member names of " + record); + } + if (expect.has("splice_literals")) { + JSONArray literals = expect.getJSONArray("splice_literals"); + for (int i = 0; i < literals.length(); i++) { + assertTrue(record.contains(literals.getString(i)), name + ": missing literal " + literals.getString(i)); + } + assertEquals("poison", deadLetterReason(record), name); + } else { + JSONObject parsed = new JSONObject(record); + JSONObject deadLetter = (JSONObject) parsed.remove("dead_letter"); + assertEquals("poison", deadLetter.getString("reason"), name); + assertEquals("orders", deadLetter.getString("original_queue"), name); + assertTrue(want.similar(parsed), name + ": " + record); + } + } + + /** The {@code reason} of the final {@code dead_letter} member, read from the record tail the splice appends. */ + private static String deadLetterReason(String record) { + int at = record.lastIndexOf("\"dead_letter\":"); + JSONObject block = new JSONObject(new org.json.JSONTokener(record.substring(at + "\"dead_letter\":".length()))); + assertEquals("orders", block.getString("original_queue")); + return block.getString("reason"); + } + + /** + * The distinct top-level member names of a JSON object text, read with a tolerant scanner (string-aware, depth + * tracking) because a conforming splice may hold values the test's own parser cannot represent. + */ + static Set topLevelKeys(String json) { + Set keys = new LinkedHashSet<>(); + int depth = 0; + int i = 0; + int n = json.length(); + while (i < n) { + char ch = json.charAt(i); + if (ch == '"') { + int start = i++; + while (i < n && json.charAt(i) != '"') { + i += json.charAt(i) == '\\' ? 2 : 1; + } + int end = i++; + if (depth == 1) { + int j = i; + while (j < n && " \t\r\n".indexOf(json.charAt(j)) >= 0) { + j++; + } + if (j < n && json.charAt(j) == ':') { + keys.add(new JSONArray("[" + json.substring(start, end + 1) + "]").getString(0)); + } + } + continue; + } + if (ch == '{' || ch == '[') { + depth++; + } else if (ch == '}' || ch == ']') { + depth--; + } + i++; + } + return keys; + } +} diff --git a/src/test/resources/conformance/CONFORMANCE_VERSION b/src/test/resources/conformance/CONFORMANCE_VERSION index dc1e644..27f9cd3 100644 --- a/src/test/resources/conformance/CONFORMANCE_VERSION +++ b/src/test/resources/conformance/CONFORMANCE_VERSION @@ -1 +1 @@ -1.6.0 +1.8.0 diff --git a/src/test/resources/conformance/manifest.json b/src/test/resources/conformance/manifest.json index 63f7ca1..4b2accb 100644 --- a/src/test/resources/conformance/manifest.json +++ b/src/test/resources/conformance/manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "conformance_version": "1.6.0", + "conformance_version": "1.8.0", "sections": { "cases": { "scope": "core" @@ -79,6 +79,24 @@ }, "poison_dlq": { "scope": "core" + }, + "identity_verdict": { + "scope": "core" + }, + "pulsar_exhaustion": { + "scope": "pulsar" + }, + "version_gate_pulsar": { + "scope": "pulsar" + }, + "version_gate_artemis": { + "scope": "artemis" + }, + "version_gate_sqs": { + "scope": "sqs" + }, + "version_gate_asb": { + "scope": "asb" } }, "exceptions": [ @@ -5798,5 +5816,1508 @@ } } ] + }, + "identity_verdict": { + "description": "Body classification outside Kafka (ADR-0036 decision 4; ADR-0032 section 6; error-handling.md section 3). Scope: `core`, because the decoder verdict is the same for every binding. For each case, feed `body` (a JSON string; it is the raw message body, so a case may hold invalid JSON, a BOM or duplicate keys) to the SDK core decoder and assert the verdict `expect`: `ok` - the body decodes into an envelope with a valid identity; `poison` - it does not (a decode error, or no valid identity). On a transport other than Kafka a `poison` body is never handed to a handler: it takes the poison route (broker-bindings.md section 5.5 and error-handling.md section 7; ADR-0035 record with `reason: poison`), not the unknown-URN strategies of error-handling.md section 3, which remain for a valid envelope whose URN has no handler. The case set starts with EVERY `kafka_poison` case (same names, bodies and order, the first 92 cases), with `no_identity` mapped to `poison` and `ok` / `poison` unchanged, so the two sections stay in step; the 5 cases after them are extra rows that pin the ADR-0036 decision 4 bodies `kafka_poison` does not hold (a blank or non-string `job` next to a valid `urn`, and a tab-only `job`) (a body with a valid identity but a wrongly typed known member is `poison` in both); a CI oracle derives the first 92 cases from `kafka_poison`, fails on any difference, and checks the extra rows with its own identity rule. The duplicate-key rule (the LAST value of a repeated top-level key wins) and the identity rule are those of `kafka_poison`: a `job` that is not a string is ignored; a blank (empty or whitespace-only) string `job` falls back to `urn`; a `urn` that is not a non-blank string is ignored; when neither is valid the body has no identity. Rows that pin the ADR-0036 decision 4 table (a `job` of `\"\"`, `\" \\t\"`, `null` or `{}` next to a valid `urn` is `ok`; see also the extra rows): a valid envelope (`full valid`) and a body whose only valid identity is `urn` (`urn alias string`), a blank `job` with a valid `urn` (`job blank with valid urn alias`), a non-string `job` with a valid `urn` (`job number with valid urn alias`) are `ok`; `{}` (`empty object`), a non-string `job` without `urn` (`job number`, `job null`), a non-string `urn` without `job` (`urn alias number`), non-object top-level values (`null literal`, `empty array`, `string`, `number`) and a non-JSON or empty body (`not json`, `empty`) are `poison`. The edge that a transport routes on an out-of-band identity property (Pulsar / Kafka `bq-job`) is outside this section.", + "cases": [ + { + "name": "empty", + "body": "", + "expect": "poison" + }, + { + "name": "whitespace only", + "body": " ", + "expect": "poison" + }, + { + "name": "not json", + "body": "not-json", + "expect": "poison" + }, + { + "name": "truncated", + "body": "{\"job\":\"urn:a\"", + "expect": "poison" + }, + { + "name": "empty array", + "body": "[]", + "expect": "poison" + }, + { + "name": "array", + "body": "[1]", + "expect": "poison" + }, + { + "name": "string", + "body": "\"str\"", + "expect": "poison" + }, + { + "name": "number", + "body": "123", + "expect": "poison" + }, + { + "name": "boolean", + "body": "true", + "expect": "poison" + }, + { + "name": "null literal", + "body": "null", + "expect": "poison" + }, + { + "name": "empty object", + "body": "{}", + "expect": "poison" + }, + { + "name": "full valid", + "body": "{\"job\":\"urn:babel:orders:created\",\"trace_id\":\"t\",\"data\":{},\"meta\":{\"id\":\"i\",\"queue\":\"q\",\"lang\":\"php\",\"schema_version\":1,\"created_at\":1700000000000},\"attempts\":0}", + "expect": "ok" + }, + { + "name": "job number", + "body": "{\"job\":5}", + "expect": "poison" + }, + { + "name": "job null", + "body": "{\"job\":null}", + "expect": "poison" + }, + { + "name": "urn alias number", + "body": "{\"urn\":5}", + "expect": "poison" + }, + { + "name": "urn alias string", + "body": "{\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "urn alias padded", + "body": "{\"urn\":\" urn:x \"}", + "expect": "ok" + }, + { + "name": "trace_id number", + "body": "{\"trace_id\":7}", + "expect": "poison" + }, + { + "name": "data array", + "body": "{\"data\":[]}", + "expect": "poison" + }, + { + "name": "data string", + "body": "{\"data\":\"x\"}", + "expect": "poison" + }, + { + "name": "data null", + "body": "{\"data\":null}", + "expect": "poison" + }, + { + "name": "meta string", + "body": "{\"meta\":\"x\"}", + "expect": "poison" + }, + { + "name": "meta array", + "body": "{\"meta\":[]}", + "expect": "poison" + }, + { + "name": "meta null", + "body": "{\"meta\":null}", + "expect": "poison" + }, + { + "name": "meta.id number", + "body": "{\"meta\":{\"id\":5}}", + "expect": "poison" + }, + { + "name": "meta.queue number", + "body": "{\"meta\":{\"queue\":5}}", + "expect": "poison" + }, + { + "name": "meta.lang boolean", + "body": "{\"meta\":{\"lang\":true}}", + "expect": "poison" + }, + { + "name": "meta.created_at string", + "body": "{\"meta\":{\"created_at\":\"2026\"}}", + "expect": "poison" + }, + { + "name": "meta.created_at fractional", + "body": "{\"meta\":{\"created_at\":1.5}}", + "expect": "poison" + }, + { + "name": "meta.created_at integer", + "body": "{\"meta\":{\"created_at\":1700000000000}}", + "expect": "poison" + }, + { + "name": "meta.created_at exponent", + "body": "{\"meta\":{\"created_at\":1e3}}", + "expect": "poison" + }, + { + "name": "meta.created_at decimal zero", + "body": "{\"meta\":{\"created_at\":1.0}}", + "expect": "poison" + }, + { + "name": "meta.created_at int64 overflow", + "body": "{\"meta\":{\"created_at\":9223372036854775808}}", + "expect": "poison" + }, + { + "name": "meta.created_at int64 max", + "body": "{\"meta\":{\"created_at\":9223372036854775807}}", + "expect": "poison" + }, + { + "name": "meta.created_at negative", + "body": "{\"meta\":{\"created_at\":-1}}", + "expect": "poison" + }, + { + "name": "meta.created_at negative zero", + "body": "{\"meta\":{\"created_at\":-0}}", + "expect": "poison" + }, + { + "name": "meta.schema_version string", + "body": "{\"meta\":{\"schema_version\":\"1\"}}", + "expect": "poison" + }, + { + "name": "meta.schema_version two", + "body": "{\"meta\":{\"schema_version\":2}}", + "expect": "poison" + }, + { + "name": "meta.schema_version decimal zero", + "body": "{\"meta\":{\"schema_version\":1.0}}", + "expect": "poison" + }, + { + "name": "meta.schema_version null", + "body": "{\"meta\":{\"schema_version\":null}}", + "expect": "poison" + }, + { + "name": "meta.schema_version boolean", + "body": "{\"meta\":{\"schema_version\":true}}", + "expect": "poison" + }, + { + "name": "attempts fractional", + "body": "{\"attempts\":1.5}", + "expect": "poison" + }, + { + "name": "attempts string", + "body": "{\"attempts\":\"3\"}", + "expect": "poison" + }, + { + "name": "attempts integer", + "body": "{\"attempts\":2}", + "expect": "poison" + }, + { + "name": "attempts exponent", + "body": "{\"attempts\":1e0}", + "expect": "poison" + }, + { + "name": "attempts upper exponent", + "body": "{\"attempts\":1E2}", + "expect": "poison" + }, + { + "name": "attempts negative zero", + "body": "{\"attempts\":-0}", + "expect": "poison" + }, + { + "name": "attempts overflow", + "body": "{\"attempts\":9223372036854775808}", + "expect": "poison" + }, + { + "name": "attempts null", + "body": "{\"attempts\":null}", + "expect": "poison" + }, + { + "name": "dead_letter string", + "body": "{\"dead_letter\":\"x\"}", + "expect": "poison" + }, + { + "name": "dead_letter null", + "body": "{\"dead_letter\":null}", + "expect": "poison" + }, + { + "name": "dead_letter empty object", + "body": "{\"dead_letter\":{}}", + "expect": "poison" + }, + { + "name": "dead_letter.attempts string", + "body": "{\"dead_letter\":{\"attempts\":\"3\"}}", + "expect": "poison" + }, + { + "name": "dead_letter.reason number", + "body": "{\"dead_letter\":{\"reason\":5}}", + "expect": "poison" + }, + { + "name": "dead_letter.error number", + "body": "{\"dead_letter\":{\"error\":5}}", + "expect": "poison" + }, + { + "name": "dead_letter nullable strings", + "body": "{\"dead_letter\":{\"error\":null,\"exception\":null}}", + "expect": "poison" + }, + { + "name": "dead_letter.failed_at string", + "body": "{\"dead_letter\":{\"failed_at\":\"x\"}}", + "expect": "poison" + }, + { + "name": "dead_letter.failed_at fractional", + "body": "{\"dead_letter\":{\"failed_at\":1.5}}", + "expect": "poison" + }, + { + "name": "dead_letter.original_queue array", + "body": "{\"dead_letter\":{\"original_queue\":[]}}", + "expect": "poison" + }, + { + "name": "dead_letter.lang object", + "body": "{\"dead_letter\":{\"lang\":{}}}", + "expect": "poison" + }, + { + "name": "dead_letter full", + "body": "{\"dead_letter\":{\"error\":\"e\",\"exception\":\"x\",\"failed_at\":1,\"original_queue\":\"q\",\"attempts\":1,\"lang\":\"go\",\"reason\":\"r\"}}", + "expect": "poison" + }, + { + "name": "upper-case job key with number is an unknown member (names are case-sensitive)", + "body": "{\"JOB\":5}", + "expect": "poison" + }, + { + "name": "mixed-case meta.id key with number is an unknown member (names are case-sensitive)", + "body": "{\"Meta\":{\"ID\":5}}", + "expect": "poison" + }, + { + "name": "upper-case job key with string", + "body": "{\"JOB\":\"urn:x\"}", + "expect": "poison" + }, + { + "name": "trailing garbage", + "body": "{\"job\":\"x\"} trailing", + "expect": "poison" + }, + { + "name": "two objects", + "body": "{\"job\":\"x\"}{\"job\":\"y\"}", + "expect": "poison" + }, + { + "name": "unknown keys", + "body": "{\"x\":1}", + "expect": "poison" + }, + { + "name": "forbidden keys with odd types", + "body": "{\"timestamp\":\"x\",\"meta\":{\"max_retries\":\"x\",\"attempts\":\"y\",\"source\":5,\"ts\":\"z\"}}", + "expect": "poison" + }, + { + "name": "nested lookalikes in data", + "body": "{\"data\":{\"a\":[1,2,{\"b\":null}],\"meta\":5,\"job\":5},\"extra\":{\"meta\":5}}", + "expect": "poison" + }, + { + "name": "duplicate job string then number", + "body": "{\"job\":\"urn:x\",\"job\":5}", + "expect": "poison" + }, + { + "name": "duplicate job number then string", + "body": "{\"job\":5,\"job\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "byte order mark", + "body": "\ufeff{\"job\":\"x\"}", + "expect": "poison" + }, + { + "name": "leading zero number", + "body": "{\"attempts\":01}", + "expect": "poison" + }, + { + "name": "NaN literal", + "body": "{\"attempts\":NaN}", + "expect": "poison" + }, + { + "name": "single quotes", + "body": "{'a':1}", + "expect": "poison" + }, + { + "name": "meta duplicate-case key", + "body": "{\"meta\":{\"id\":\"a\",\"queue\":\"b\",\"lang\":\"c\",\"schema_version\":1,\"created_at\":1,\"ID\":\"dup\"}}", + "expect": "poison" + }, + { + "name": "attempts huge exponent", + "body": "{\"attempts\":1e400}", + "expect": "poison" + }, + { + "name": "data with huge number", + "body": "{\"data\":{\"n\":1e400}}", + "expect": "poison" + }, + { + "name": "job number with valid urn alias", + "body": "{\"job\":5,\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job blank with valid urn alias", + "body": "{\"job\":\" \",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job valid with urn number", + "body": "{\"job\":\"urn:x\",\"urn\":5}", + "expect": "ok" + }, + { + "name": "job blank without urn", + "body": "{\"job\":\" \"}", + "expect": "poison" + }, + { + "name": "job and urn both empty strings", + "body": "{\"job\":\"\",\"urn\":\"\"}", + "expect": "poison" + }, + { + "name": "job and urn both arrays", + "body": "{\"job\":[],\"urn\":[]}", + "expect": "poison" + }, + { + "name": "job object without urn", + "body": "{\"job\":{}}", + "expect": "poison" + }, + { + "name": "identity with null data", + "body": "{\"job\":\"urn:x\",\"data\":null}", + "expect": "ok" + }, + { + "name": "identity with null meta", + "body": "{\"job\":\"urn:x\",\"meta\":null}", + "expect": "ok" + }, + { + "name": "identity with null attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":null}", + "expect": "ok" + }, + { + "name": "identity with null dead_letter", + "body": "{\"job\":\"urn:x\",\"dead_letter\":null}", + "expect": "ok" + }, + { + "name": "identity with integer attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":2}", + "expect": "ok" + }, + { + "name": "identity with wrong typed attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":\"3\"}", + "expect": "poison" + }, + { + "name": "identity with wrong typed data", + "body": "{\"job\":\"urn:x\",\"data\":[]}", + "expect": "poison" + }, + { + "name": "job empty string with valid urn", + "body": "{\"job\":\"\",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job space and tab with valid urn", + "body": "{\"job\":\" \\t\",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job null with valid urn", + "body": "{\"job\":null,\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job object with valid urn", + "body": "{\"job\":{},\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job space and tab without urn", + "body": "{\"job\":\" \\t\"}", + "expect": "poison" + } + ] + }, + "pulsar_exhaustion": { + "description": "Pulsar max-attempts settle decision (ADR-0036 decisions 1 and 2, amends ADR-0014; broker-bindings.md section 5.5 \"Max attempts and poison\"). Scope: `pulsar`. A pure function, no broker: for each case compute the outcome the consumer takes for a delivery and assert `expect`. Case `{name, kind, body_attempts?, bq_attempts?, redelivery_count, max_attempts?, dead_letter, on_unknown_urn?, expect}`. `kind`: `failed` (the handler throws), `unknown_urn` (an unmapped URN with no hook; strategy `fail`), `poison` (the body does not decode into an envelope, including an object with no valid identity, see `identity_verdict`). Inputs: `body_attempts` is the envelope's top-level `attempts` (absent for `poison`, which has no decodable body: its input is `bq_attempts` and `redelivery_count` only); `bq_attempts` is the `bq-attempts` message property, a string (absent = not set); `redelivery_count` is the native Pulsar RedeliveryCount (0-based); `max_attempts` absent = the default 3 (the total number of deliveries, the first included; a value below 1 is a SHOULD-level configuration error and is not a case); `dead_letter` is a boolean (dead-lettering on or off) and `off` has two meanings: in the Go and Python runtimes it is the dead-letter SETTING only, the DLQ target stays writable and the `dead_letter` strategy still writes a record; in the PHP `Consume\\Dispatcher` \"on\" means a `DeadLetterPublisher` is configured, so `off` means NO publisher and nothing can be written; `on_unknown_urn` absent = `fail`. Derivation: `reconciled` = `bq_attempts` when present and valid, else `body_attempts`, then `max(reconciled, redelivery_count)` (pulsar.attempts_reconciliation); `counted = reconciled + 1`. Decision. `failed` and `unknown_urn` (strategy `fail`): `counted < max_attempts` is `retry`; otherwise dead-lettering on is `dlq` (`reason` `failed` or `unknown_urn`, `dead_letter_attempts = counted`) and off is `drop` (acknowledge and log). `poison`: `on_unknown_urn: delete` is `drop`; `on_unknown_urn: dead_letter` is `dlq` with `reason: poison` whatever `dead_letter` says (the strategy writes the record where a DLQ writer exists; see `outcome_no_writer` below); dead-lettering on (strategy `fail` or `release`) is `dlq` with `reason: poison` and NO retry (the ADR-0035 record, `dead_letter_attempts` is not compared); dead-lettering off takes the bounded `fail` path (`release` is replaced by it) - `counted < max_attempts` is `retry`, otherwise `drop`. `unknown_urn` with `on_unknown_urn: delete` is `drop`; with `dead_letter` it is a direct `dlq` with `reason: unknown_urn` whatever `dead_letter` says (`dead_letter_attempts` is not compared; see `outcome_no_writer` below). `expect.outcome_no_writer` (optional, `drop`): present only on a row whose result differs for an SDK without a DLQ writer (the PHP `Dispatcher` without a publisher: `on_unknown_urn: dead_letter` is acknowledged, dropped and logged; owner decision 2026-10-25, ADR-0036 decision 3). A runner for such an SDK asserts `expect.outcome_no_writer` instead of `expect.outcome`; every other runner asserts `expect.outcome` and ignores the field; when the field is absent both kinds of SDK assert `expect.outcome`. On Kafka the PHP `Dispatcher` rethrows instead of dropping (outside this Pulsar-scoped section). Rows with any `on_unknown_urn` value (they are the rows with that key) apply only to an SDK that has the strategy option (the Go and Python runtimes and the PHP `Consume\\Dispatcher`, ADR-0036 decision 3); a runner for the Java, .NET or Node consumer, which have no such option, skips them and reports them as `skip`, not `pass`. `unknown_urn` with `release` is not a case: on Pulsar the Go and Python runtimes release it immediately without counting (the `unknown_urn_release_delay` known gap of broker-bindings.md section 5.5). Not covered here (no broker-less test): the DLQ write itself, the acknowledgement after a successful write, the negative acknowledgement after a failed one, and the PHP queue name of ADR-0036 decision 3; each SDK covers them in its own tests.", + "cases": [ + { + "name": "failed-reconciled-0-dlq-on-retries", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-reconciled-1-dlq-on-retries", + "kind": "failed", + "body_attempts": 1, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-reconciled-2-dlq-on-dead-letters", + "kind": "failed", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "failed-reconciled-2-dlq-off-drops", + "kind": "failed", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "failed-reconciled-7-dlq-on-counts-past-the-cap", + "kind": "failed", + "body_attempts": 7, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 8 + } + }, + { + "name": "failed-max-1-dlq-on-first-delivery-dead-letters", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "max_attempts": 1, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 1 + } + }, + { + "name": "failed-max-1-dlq-off-first-delivery-drops", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "max_attempts": 1, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "failed-max-5-reconciled-3-dlq-off-retries", + "kind": "failed", + "body_attempts": 3, + "redelivery_count": 0, + "max_attempts": 5, + "dead_letter": false, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-max-5-reconciled-4-dlq-off-drops", + "kind": "failed", + "body_attempts": 4, + "redelivery_count": 0, + "max_attempts": 5, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "unknown-urn-reconciled-1-dlq-on-retries", + "kind": "unknown_urn", + "body_attempts": 1, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "unknown-urn-reconciled-2-dlq-on-dead-letters", + "kind": "unknown_urn", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "unknown_urn", + "dead_letter_attempts": 3 + } + }, + { + "name": "unknown-urn-reconciled-2-dlq-off-drops", + "kind": "unknown_urn", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-reconciled-0-dlq-on-dead-letters-without-retry", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "poison-reconciled-0-dlq-off-retries", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "retry" + } + }, + { + "name": "poison-reconciled-2-dlq-off-drops", + "kind": "poison", + "redelivery_count": 2, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-delete-dlq-on-drops", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-delete-dlq-off-drops", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-release-dlq-on-dead-letters-without-retry", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "release", + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "poison-release-dlq-off-reconciled-0-takes-the-bounded-fail-path", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "release", + "expect": { + "outcome": "retry" + } + }, + { + "name": "poison-release-dlq-off-reconciled-2-drops-not-released-forever", + "kind": "poison", + "redelivery_count": 2, + "dead_letter": false, + "on_unknown_urn": "release", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-dead-letter-strategy-dlq-off-dead-letters", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "poison", + "outcome_no_writer": "drop" + } + }, + { + "name": "unknown-urn-delete-dlq-on-drops", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "unknown-urn-dead-letter-strategy-dlq-off-dead-letters-directly", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "unknown_urn", + "outcome_no_writer": "drop" + } + }, + { + "name": "poison-dead-letter-strategy-dlq-on-dead-letters", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "unknown-urn-dead-letter-strategy-dlq-on-dead-letters-directly", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "unknown_urn" + } + }, + { + "name": "reconciliation-bq-attempts-string-over-body", + "kind": "failed", + "body_attempts": 0, + "bq_attempts": "2", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "reconciliation-redelivery-count-floors-body", + "kind": "failed", + "body_attempts": 1, + "redelivery_count": 2, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "reconciliation-bq-attempts-below-cap-retries", + "kind": "unknown_urn", + "body_attempts": 0, + "bq_attempts": "1", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "reconciliation-poison-bq-attempts-dlq-off-drops", + "kind": "poison", + "bq_attempts": "2", + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + } + ] + }, + "version_gate_pulsar": { + "description": "Version-gate settle rule for Apache Pulsar (broker-bindings.md section 5.7, property `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `pulsar`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. A row with any value other than `fail` applies only to an SDK whose consumer has the strategy option; the Java, .NET and Node Pulsar consumers have none (they behave as `fail`), so a runner for one of them reports those rows as `skip` citing ADR-0037, not as `pass`. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; negative acknowledgement). With dead-lettering off and a strategy other than `dead_letter` the outcome is `ack` (acknowledged and dropped, logged). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `ack`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-ack", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "ack" + } + }, + { + "name": "release-dlq-off-ack", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "ack" + } + } + ] + }, + "version_gate_artemis": { + "description": "Version-gate settle rule for Apache ActiveMQ Artemis (broker-bindings.md section 7.7, property `bq_schema_version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `artemis`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. Every row is asserted: the Java, .NET, Node, Go and Python consumers of this binding have the strategy option, so none of its rows may be skipped for lack of it. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; JMS `session.recover()` / AMQP `released`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `ack` (acknowledged and dropped, logged). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `ack`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-ack", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "ack" + } + }, + { + "name": "release-dlq-off-ack", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "ack" + } + } + ] + }, + "version_gate_sqs": { + "description": "Version-gate settle rule for Amazon SQS (broker-bindings.md section 3.7, message attribute `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `sqs`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. Every row is asserted: the Java, .NET, Node, Go and Python consumers of this binding have the strategy option, so none of its rows may be skipped for lack of it. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; no `DeleteMessage`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `native` (the SDK does not settle it and leaves it to the broker's native mechanism: SQS no `DeleteMessage`, so visibility expiry redelivers it and the queue's `RedrivePolicy` / retention applies; it is reported, not retried by the SDK). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `native`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-native", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "native" + } + }, + { + "name": "release-dlq-off-native", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "native" + } + } + ] + }, + "version_gate_asb": { + "description": "Version-gate settle rule for Azure Service Bus (broker-bindings.md section 4.7, application property `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `asb`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. A row with any value other than `fail` applies only to an SDK whose consumer has the strategy option; the Java, .NET and Node Azure Service Bus consumers have only an `onUnknownUrn` callback and no strategy option (they behave as `fail`), so a runner for one of them reports those rows as `skip` citing ADR-0037, not as `pass`; the Go and Python consumers have the option and assert every row. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; `Abandon`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `native` (the SDK does not settle it and leaves it to the broker's native mechanism: an explicit native `DeadLetter(...)` into `$DeadLetterQueue` or `Abandon` until `MaxDeliveryCount`; it is reported, not retried by the SDK). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `native`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-native", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "native" + } + }, + { + "name": "release-dlq-off-native", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "native" + } + } + ] } } diff --git a/src/test/resources/conformance/schema/results.example.json b/src/test/resources/conformance/schema/results.example.json index 9130d29..e2b7bb1 100644 --- a/src/test/resources/conformance/schema/results.example.json +++ b/src/test/resources/conformance/schema/results.example.json @@ -1,7 +1,7 @@ { "sdk": "example-sdk/redis", "version": "1.9.0", - "conformance_version": "1.6.0", + "conformance_version": "1.8.0", "generated_at": "2026-10-18T09:30:00Z", "bindings": [ "redis",