diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f96269..efd4e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,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 9b71f8e..6f83440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.1.0] - 2026-10-26 + +MINOR (ADR-0037): poison bodies are dead-lettered as the ADR-0035 record and the version gate follows one +settle rule. The envelope is unchanged (`schema_version` stays `1`). + +### Changed (behaviour) + +- **Poison body** (not JSON, not an envelope, no valid identity, or an unsupported `meta.schema_version`): + the DLQ record is now the **ADR-0035 record** (`PoisonRecord`: an object body spliced with a `dead_letter` + block, `reason: "poison"`, `original_queue` = the consumed queue; any other body under `raw`, base64 with + `raw_encoding` when it is not valid UTF-8) instead of a forward of the raw body. It is acknowledged only + after the write succeeded (otherwise `recover()`, as before). Under `unknownUrn(DELETE)` it is now + acknowledged without a record. With no dead-letter queue it is acknowledged, dropped and **logged** + (unchanged, now visible). +- **Version-gated delivery** (`bq_schema_version` present, not blank, not `1`): settled on the first delivery, + never decoded, never retried. The raw body is still forwarded to the DLQ byte for byte (no `dead_letter` + block) and acknowledged after the write, but now **not** under `unknownUrn(DELETE)`: that acknowledges it + without a record (**dropped**). With no dead-letter queue it is acknowledged, dropped and logged. +- **`BytesMessage` bodies are read, not dropped.** `JmsMessages.text(...)` treated every non-`TextMessage` as an + empty body, so a message from an AMQP 1.0 producer (its Data section arrives as a `BytesMessage`) was decoded as + `""`, and a poison one was recorded as `{"raw": ""}` and acknowledged, losing its bytes. The consumer now reads the + exact body bytes of a `TextMessage` (UTF-8; empty when unset) or a `BytesMessage` (new `JmsMessages.bodyBytes`) + and decodes them with a **strict** UTF-8 check: a valid envelope in a `BytesMessage` is routed normally; a body + that is not valid UTF-8 (never decoded leniently) is poison and its exact bytes go to the ADR-0035 record + (`raw` base64 with `raw_encoding: "base64"`). A version-gated `BytesMessage` is forwarded to the DLQ as a + `BytesMessage` with the same bytes. A `TextMessage` with no body is read as empty instead of `null`. +- Requires `babelqueue-core` 1.13.0 (was 1.11.0) for `PoisonRecord`. +- Vendored the cross-SDK conformance suite v1.8.0 (`version_gate_artemis`, `poison_dlq` runners added) and moved + the CI pin to it. + ## [1.0.3] - 2026-10-17 ### Fixed diff --git a/README.md b/README.md index f895a63..e4a92ce 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ them instead of re-implementing them (the envelope stays `schema_version: 1`): com.babelqueue babelqueue-artemis - 1.0.3 + 1.1.0 ``` @@ -115,8 +115,16 @@ additive. `bq_schema_version` (underscores — JMS forbids hyphens in property names, ADR-0017). Absent or blank (empty / ASCII whitespace only) → normal decode. Any other value that is not exactly `1` (no trimming — `"2"`, `"x"`, `" 1"`) → the message is **not** decoded and the handler is not called: -`onError` is notified with a `null` envelope, the raw body is forwarded to `.dlq` (if -configured) and the message is acknowledged. +`onError` is notified with a `null` envelope and the message is settled on its **first** delivery +(ADR-0037): under the `delete` strategy it is acknowledged; otherwise the raw body is forwarded to +`.dlq` **exactly as received** (no `dead_letter` block) and acknowledged after the write succeeded (a +failed write `recover()`s the session, so it is redelivered and rejected again); with no dead-letter queue it +is acknowledged and the drop is logged. + +**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. With a dead-letter queue it is written to +`.dlq` as the ADR-0035 record (`reason: "poison"`; the original body kept, spliced or under `raw`) and +acknowledged after the write; under `delete`, or with no dead-letter queue, it is acknowledged (and logged). ## Build & test diff --git a/pom.xml b/pom.xml index ea6b1cd..6daabfe 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.babelqueue babelqueue-artemis - 1.0.3 + 1.1.0 jar BabelQueue Apache ActiveMQ Artemis @@ -49,7 +49,7 @@ 17 UTF-8 UTF-8 - 1.11.0 + 1.13.0 3.1.0 5.10.3 5.12.0 diff --git a/src/main/java/com/babelqueue/artemis/ArtemisConsumer.java b/src/main/java/com/babelqueue/artemis/ArtemisConsumer.java index b3cdac6..2fc07ef 100644 --- a/src/main/java/com/babelqueue/artemis/ArtemisConsumer.java +++ b/src/main/java/com/babelqueue/artemis/ArtemisConsumer.java @@ -4,15 +4,21 @@ import com.babelqueue.DeadLetters; import com.babelqueue.Envelope; import com.babelqueue.EnvelopeCodec; +import com.babelqueue.JsonValues; +import com.babelqueue.PoisonRecord; import com.babelqueue.UnknownUrnException; import com.babelqueue.UnknownUrnStrategy; +import jakarta.jms.BytesMessage; import jakarta.jms.JMSException; import jakarta.jms.Message; import jakarta.jms.MessageConsumer; import jakarta.jms.MessageProducer; +import jakarta.jms.Queue; import jakarta.jms.Session; import jakarta.jms.TextMessage; +import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.function.BooleanSupplier; @@ -27,19 +33,28 @@ * {@code max(body, JMSXDeliveryCount − 1)} — {@code JMSXDeliveryCount} is the broker-maintained, * 1-based authoritative counter. * - *

Per §7.7 the {@code bq_schema_version} string property is checked before the body is - * decoded: when it is present, not blank (empty or ASCII-whitespace-only) and not exactly - * {@link EnvelopeCodec#SCHEMA_VERSION} (compared as sent, no trimming), the message is never decoded and - * the handler is never called — {@code onError} is notified (with a {@code null} envelope), the raw body - * is forwarded to the DLQ (when configured) and the message is acknowledged — but only after the DLQ write succeeded. If it fails, the session is - * {@code recover()}ed (the message is redelivered and rejected again, never lost, never handled — a later - * message's session-wide {@code acknowledge()} cannot swallow it) and the failure goes to {@code onError}. - * An absent or blank property + *

Version gate (§7.7, ADR-0037 decision 1). The {@code bq_schema_version} string property is checked + * before the body is decoded: when it is present, not blank (empty or ASCII-whitespace-only) and not exactly + * {@link EnvelopeCodec#SCHEMA_VERSION} (compared as sent, no trimming), the message is never decoded, the handler is + * never called and nothing is retried, on its first delivery whatever {@code JMSXDeliveryCount} says. {@code onError} + * is notified (with a {@code null} envelope). Under the {@code delete} {@link Builder#unknownUrn strategy} it is + * acknowledged. Otherwise, with a {@link Builder#deadLetterQueue dead-letter queue}, the body is forwarded to the DLQ + * exactly as received (no {@code dead_letter} block) and the message acknowledged — but only after the DLQ write + * succeeded; if it fails, the session is {@code recover()}ed (the message is redelivered and rejected again, never + * lost, never handled — a later message's session-wide {@code acknowledge()} cannot swallow it) and the failure goes + * to {@code onError}. With no dead-letter queue it is acknowledged and the drop is logged. An absent or blank property * changes nothing, and the post-decode {@link EnvelopeCodec#accepts} check still applies. * - *

The same rule holds for every other DLQ write (a non-conformant envelope, an unmapped URN under - * {@code dead_letter}, a handler that exhausted max-tries): the message is acknowledged only after the DLQ write - * succeeded; otherwise the session is {@code recover()}ed and the failure goes to {@code onError}. + *

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. Under {@code delete} it is + * acknowledged; with a dead-letter queue the ADR-0035 record ({@link PoisonRecord}, {@code reason: "poison"}, + * {@code original_queue} = the consumed queue) is written to it and the message acknowledged only after the write + * succeeded (same recover-on-failure rule as above); with no dead-letter queue it is acknowledged and the drop is + * logged. + * + *

The same acknowledge-only-after-the-write rule holds for every other DLQ write (an unmapped URN under + * {@code dead_letter}, a handler that exhausted max-tries): otherwise the session is {@code recover()}ed and the + * failure goes to {@code onError}. * *

Process one message per {@link #poll()} so the per-message {@code acknowledge()} and * {@code recover()} stay correct under the session-wide ack model. Not thread-safe — a JMS @@ -47,6 +62,8 @@ */ public final class ArtemisConsumer { + private static final System.Logger LOGGER = System.getLogger(ArtemisConsumer.class.getName()); + /** * Notified of a non-conformant message, an unmapped URN, a throwing handler, or a message rejected by the * §7.7 schema-version gate. {@code envelope} is {@code null} when the body was not decoded. @@ -111,6 +128,19 @@ private void handle(Message message) throws JMSException { BabelQueueException rejected = new BabelQueueException("Rejected a BabelQueue message with unsupported " + JmsMessages.SCHEMA_VERSION + " property '" + declaredVersion + "' (supported: " + EnvelopeCodec.SCHEMA_VERSION + "); body not decoded."); + if (UnknownUrnStrategy.DELETE.equals(unknownUrn)) { + message.acknowledge(); + report(rejected, null, message); + return; + } + if (!deadLetterEnabled()) { + LOGGER.log(System.Logger.Level.WARNING, + "BabelQueue Artemis: dropped a version-gated message (no dead-letter queue configured): {0}", + rejected.getMessage()); + message.acknowledge(); + report(rejected, null, message); + return; + } // CLIENT_ACKNOWLEDGE is session-wide: a later message's acknowledge() would also acknowledge this // one. So if the DLQ write fails, force redelivery (recover) instead of leaving it unacknowledged — // the message is never lost and never reaches the handler; it is rejected again when redelivered. @@ -130,12 +160,16 @@ private void handle(Message message) throws JMSException { return; } - Envelope envelope = reconcile(EnvelopeCodec.decode(JmsMessages.text(message)), message); + // Strict UTF-8 over the exact body bytes (a TextMessage's text or a BytesMessage's content): a body that is not + // valid UTF-8 is never decoded leniently, it is poison (ADR-0037 decision 2). + String text = JmsMessages.strictUtf8(JmsMessages.bodyBytes(message)); + Envelope envelope = reconcile(EnvelopeCodec.decode(text == null ? "" : text), message); if (!EnvelopeCodec.accepts(envelope)) { - report(new BabelQueueException("Rejected a non-conformant BabelQueue envelope from Artemis."), - envelope, message); - settle(message, envelope, () -> deadLetterRaw(message), "dead-letter a non-conformant message"); + BabelQueueException cause = new BabelQueueException( + "Rejected a non-conformant BabelQueue envelope from Artemis."); + report(cause, envelope, message); + settlePoison(message, envelope, cause); return; } @@ -155,6 +189,25 @@ private void handle(Message message) throws JMSException { } } + /** + * Settles a poison body (ADR-0037 decision 2): acknowledged under {@code delete}, written to the DLQ as the + * ADR-0035 record and acknowledged after the write, or acknowledged and logged when no DLQ is configured. + */ + private void settlePoison(Message message, Envelope envelope, Throwable cause) throws JMSException { + if (UnknownUrnStrategy.DELETE.equals(unknownUrn)) { + message.acknowledge(); + return; + } + if (!deadLetterEnabled()) { + LOGGER.log(System.Logger.Level.WARNING, + "BabelQueue Artemis: dropped a poison message (no dead-letter queue configured): {0}", + cause.getMessage()); + message.acknowledge(); + return; + } + settle(message, envelope, () -> deadLetterPoison(message, cause), "dead-letter a poison message"); + } + /** {@code attempts = max(body, JMSXDeliveryCount − 1)}: count 1 (or absent) keeps the body's count. */ private static Envelope reconcile(Envelope envelope, Message message) { int count = JmsMessages.deliveryCount(message); @@ -228,12 +281,63 @@ envelope, reason, originalQueue(envelope), envelope.attempts(), dlqProducer.send(session.createQueue(dlqQueue), JmsMessages.project(session, annotated)); } - /** Forward an undecodable message's raw body to the DLQ (no envelope to annotate). */ + /** Whether dead-lettering is on: a DLQ producer and queue name are configured. */ + private boolean deadLetterEnabled() { + return dlqQueue != null && dlqProducer != null; + } + + /** Writes the ADR-0035 record of a poison body to the DLQ. */ + private void deadLetterPoison(Message message, Throwable cause) throws JMSException { + String record = poisonRecord(JmsMessages.bodyBytes(message), + consumedQueue(message), Math.max(JmsMessages.deliveryCount(message) - 1, 0), cause); + dlqProducer.send(session.createQueue(dlqQueue), session.createTextMessage(record)); + } + + /** + * The ADR-0035 DLQ record of a poison {@code body}: the one builder this consumer writes with (every conformance + * {@code poison_dlq} case reaches it through {@link #poll()}, invalid UTF-8 as a {@code BytesMessage}). + */ + 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 consumed queue's name: the message's JMS destination, else the DLQ name without its {@code .dlq} suffix. */ + private String consumedQueue(Message message) { + try { + if (message.getJMSDestination() instanceof Queue queue && queue.getQueueName() != null) { + return queue.getQueueName(); + } + } catch (JMSException unreadable) { + // fall through to the DLQ-derived name + } + return dlqQueue != null && dlqQueue.endsWith(".dlq") + ? dlqQueue.substring(0, dlqQueue.length() - ".dlq".length()) + : ""; + } + + /** Forward a version-gated message's raw body to the DLQ, exactly as received (no envelope to annotate). */ private void deadLetterRaw(Message message) throws JMSException { - if (dlqQueue == null || dlqProducer == null) { + if (!deadLetterEnabled()) { return; } - TextMessage forwarded = session.createTextMessage(JmsMessages.text(message)); + byte[] body = JmsMessages.bodyBytes(message); + Message forwarded; + if (message instanceof BytesMessage) { + // Keep the message kind: the bytes (valid UTF-8 or not) are forwarded exactly as received. + BytesMessage bytes = session.createBytesMessage(); + bytes.writeBytes(body); + forwarded = bytes; + } else { + forwarded = session.createTextMessage(new String(body, StandardCharsets.UTF_8)); + } dlqProducer.send(session.createQueue(dlqQueue), forwarded); } diff --git a/src/main/java/com/babelqueue/artemis/JmsMessages.java b/src/main/java/com/babelqueue/artemis/JmsMessages.java index b9620d6..ba854eb 100644 --- a/src/main/java/com/babelqueue/artemis/JmsMessages.java +++ b/src/main/java/com/babelqueue/artemis/JmsMessages.java @@ -2,10 +2,15 @@ import com.babelqueue.Envelope; import com.babelqueue.EnvelopeCodec; +import jakarta.jms.BytesMessage; import jakarta.jms.JMSException; import jakarta.jms.Message; import jakarta.jms.Session; import jakarta.jms.TextMessage; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; /** * Projects the envelope's contract fields onto a native JMS message ({@code JMSType} = URN, @@ -48,9 +53,53 @@ static TextMessage project(Session session, Envelope envelope) throws JMSExcepti return message; } - /** The body of a {@link TextMessage}, or empty for any other message type. */ - static String text(Message message) throws JMSException { - return message instanceof TextMessage textMessage ? textMessage.getText() : ""; + /** + * The exact body bytes of a received message: the UTF-8 of a {@link TextMessage} text (empty when it has none) or + * the content of a {@link BytesMessage} (what an AMQP 1.0 producer's Data section arrives as); empty for any other + * message type. Nothing is decoded or substituted, so a body that is not valid UTF-8 keeps its bytes. + */ + static byte[] bodyBytes(Message message) throws JMSException { + if (message instanceof TextMessage textMessage) { + String text = textMessage.getText(); + return text == null ? new byte[0] : text.getBytes(StandardCharsets.UTF_8); + } + if (message instanceof BytesMessage bytesMessage) { + bytesMessage.reset(); + try { + long length = bytesMessage.getBodyLength(); + if (length > Integer.MAX_VALUE - 8) { + throw new JMSException("BytesMessage body too large to read: " + length + " bytes"); + } + byte[] body = new byte[(int) length]; + int read = 0; + while (read < body.length) { + byte[] chunk = new byte[body.length - read]; + int n = bytesMessage.readBytes(chunk); + if (n <= 0) { + break; + } + System.arraycopy(chunk, 0, body, read, n); + read += n; + } + return read == body.length ? body : java.util.Arrays.copyOf(body, read); + } finally { + // Leave the read position at the start, so a handler given the raw message can read it too. + bytesMessage.reset(); + } + } + return new byte[0]; + } + + /** The bytes as text, or {@code null} when they are not valid UTF-8 (never U+FFFD substitution). */ + static String strictUtf8(byte[] body) { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(body)).toString(); + } catch (CharacterCodingException invalid) { + return null; + } } /** The {@code JMSType} (the URN), or {@code null} if unset / unreadable. */ diff --git a/src/test/java/com/babelqueue/artemis/ArtemisConformanceTest.java b/src/test/java/com/babelqueue/artemis/ArtemisConformanceTest.java index 1a83862..22d8e56 100644 --- a/src/test/java/com/babelqueue/artemis/ArtemisConformanceTest.java +++ b/src/test/java/com/babelqueue/artemis/ArtemisConformanceTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -13,6 +14,7 @@ import com.babelqueue.Envelope; import com.babelqueue.EnvelopeCodec; +import jakarta.jms.JMSException; import jakarta.jms.MessageConsumer; import jakarta.jms.MessageProducer; import jakarta.jms.Queue; @@ -24,11 +26,14 @@ import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; /** * Apache ActiveMQ Artemis binding conformance against the vendored canonical suite's * {@code artemis} block: the §7 JMS projection (JMSType / JMSCorrelationID + the {@code bq-} - * string properties) and the {@code attempts = max(body, JMSXDeliveryCount − 1)} reconciliation. + * string properties) and the {@code attempts = max(body, JMSXDeliveryCount − 1)} reconciliation, plus the + * ADR-0037 {@code version_gate_artemis} settle rule and the ADR-0035 {@code poison_dlq} record shape driven through + * this consumer. * The conformance {@code delivery_count} is the 0-based AMQP counter; the JMS binding reads the * 1-based {@code JMSXDeliveryCount}, so the test sets it to {@code delivery_count + 1}. JMS is * mocked with Mockito — no Artemis, no network. @@ -152,4 +157,122 @@ void schemaVersionGateMatchesGolden() throws Exception { verify(session, never()).recover(); } } + // ---- 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"); + // Every case, invalid UTF-8 included, goes through the consumer: a text body arrives as a TextMessage, + // raw bytes (an AMQP 1.0 Data section) as a BytesMessage. + jakarta.jms.Message message; + if (testCase.has("body_base64")) { + message = ArtemisConsumerTest.bytesMessage(PoisonRecordChecks.bodyOf(testCase)); + } else { + TextMessage text = mock(TextMessage.class); + when(text.getText()).thenReturn(testCase.getString("body")); + message = text; + } + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + MessageConsumer consumer = mock(MessageConsumer.class); + when(consumer.receive(anyLong())).thenReturn(message); + int[] handled = {0}; + + ArtemisConsumer.builder(consumer, session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .handler(URN, (envelopeIn, messageIn) -> handled[0]++) + .build() + .poll(); + + assertEquals(0, handled[0], name); + verify(dlqProducer, times(1)).send(any(Queue.class), any(jakarta.jms.Message.class)); + verify(message, times(1)).acknowledge(); + String record = written.getValue(); + PoisonRecordChecks.check(testCase, record); + } + } + + // ---- version_gate_artemis (ADR-0037 decision 1) ----------------------------------------------- + + @Test + void versionGateSettleRuleMatchesGolden() throws Exception { + JSONObject section = new JSONObject(resource("manifest.json")).getJSONObject("version_gate_artemis"); + JSONArray cases = section.getJSONArray("cases"); + JSONArray bodies = section.getJSONArray("incoming_bodies"); + assertEquals(17, cases.length()); + for (int i = 0; i < cases.length(); i++) { + for (int b = 0; b < bodies.length(); b++) { + runVersionGateCase(cases.getJSONObject(i), bodies.getJSONObject(b).getString("body")); + } + } + } + + private static void runVersionGateCase(JSONObject testCase, String body) throws Exception { + String label = testCase.getString("name"); + boolean deadLetter = "on".equals(testCase.getString("dead_letter")); + JSONObject expect = testCase.getJSONObject("expect"); + + TextMessage message = mock(TextMessage.class); + when(message.getText()).thenReturn(body); + when(message.getJMSType()).thenReturn(URN); + when(message.getStringProperty("bq_schema_version")).thenReturn("2"); + if (testCase.has("bq_attempts")) { + when(message.propertyExists("bq_attempts")).thenReturn(true); + when(message.getStringProperty("bq_attempts")).thenReturn(testCase.getString("bq_attempts")); + when(message.propertyExists("JMSXDeliveryCount")).thenReturn(true); + when(message.getIntProperty("JMSXDeliveryCount")).thenReturn(8); + } + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + if ("fails".equals(testCase.optString("dlq_write", "ok"))) { + doThrow(new JMSException("dlq write failed")).when(dlqProducer) + .send(any(Queue.class), any(jakarta.jms.Message.class)); + } + MessageConsumer consumer = mock(MessageConsumer.class); + when(consumer.receive(anyLong())).thenReturn(message); + int[] handled = {0}; + ArtemisConsumer.Builder builder = ArtemisConsumer.builder(consumer, session) + .handler(URN, (envelopeIn, messageIn) -> handled[0]++); + if (testCase.has("on_unknown_urn")) { + builder.unknownUrn(testCase.getString("on_unknown_urn")); + } + if (deadLetter) { + builder.deadLetterQueue(dlqProducer, "orders.dlq"); + } + builder.build().poll(); + + assertEquals(0, handled[0], label); + // "off" means no DLQ writer exists in this consumer: 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" -> { + verify(dlqProducer, times(1)).send(any(Queue.class), any(jakarta.jms.Message.class)); + assertEquals(body, written.getValue(), label); + assertTrue(!written.getValue().contains("\"dead_letter\""), label); + verify(message, times(1)).acknowledge(); + verify(session, never()).recover(); + } + case "ack" -> { + verify(dlqProducer, never()).send(any(Queue.class), any(jakarta.jms.Message.class)); + verify(message, times(1)).acknowledge(); + verify(session, never()).recover(); + } + case "unsettled" -> { + verify(message, never()).acknowledge(); + verify(session, times(1)).recover(); + } + default -> throw new AssertionError("unknown outcome in " + label + ": " + outcome); + } + } } diff --git a/src/test/java/com/babelqueue/artemis/ArtemisConsumerTest.java b/src/test/java/com/babelqueue/artemis/ArtemisConsumerTest.java index 193a0aa..3a6d9e6 100644 --- a/src/test/java/com/babelqueue/artemis/ArtemisConsumerTest.java +++ b/src/test/java/com/babelqueue/artemis/ArtemisConsumerTest.java @@ -54,6 +54,24 @@ private static TextMessage incoming(String text, String jmsType, int deliveryCou return message; } + /** A received {@code BytesMessage} (what an AMQP 1.0 Data section arrives as) holding exactly {@code content}. */ + static jakarta.jms.BytesMessage bytesMessage(byte[] content) throws Exception { + jakarta.jms.BytesMessage message = mock(jakarta.jms.BytesMessage.class); + when(message.getBodyLength()).thenReturn((long) content.length); + when(message.readBytes(any(byte[].class))).thenAnswer(invocation -> { + byte[] target = invocation.getArgument(0); + System.arraycopy(content, 0, target, 0, Math.min(target.length, content.length)); + return Math.min(target.length, content.length); + }); + return message; + } + + private static MessageConsumer consumerWithAny(Message message) throws Exception { + MessageConsumer consumer = mock(MessageConsumer.class); + when(consumer.receive(anyLong())).thenReturn(message); + return consumer; + } + private static MessageConsumer consumerWith(TextMessage message) throws Exception { MessageConsumer consumer = mock(MessageConsumer.class); when(consumer.receive(anyLong())).thenReturn(message); @@ -236,6 +254,171 @@ void nonConformantForwardedRawToDlq() throws Exception { org.junit.jupiter.api.Assertions.assertNotNull(reported[0]); } + // ---- ADR-0037: poison record and version gate ----------------------------------------------- + + @Test + void poisonBodyIsWrittenAsTheAdr0035RecordThenAcknowledged() throws Exception { + TextMessage message = incoming("{\"not\":\"an envelope\",\"big\":12345678901234567890}", null, 3); + Queue source = mock(Queue.class); + when(source.getQueueName()).thenReturn("orders"); + when(message.getJMSDestination()).thenReturn(source); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + int[] handled = {0}; + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .handler(URN, (env, m) -> handled[0]++) + .build() + .poll(); + + assertEquals(0, handled[0]); + verify(dlqProducer).send(any(Queue.class), any(Message.class)); + verify(message).acknowledge(); + verify(session, never()).recover(); + // An object body is spliced: its members stay as written (no re-encoding), plus the dead_letter block. + assertTrue(written.getValue().contains("\"big\":12345678901234567890"), written.getValue()); + org.json.JSONObject block = new org.json.JSONObject(written.getValue()).getJSONObject("dead_letter"); + assertEquals("poison", block.getString("reason")); + assertEquals("orders", block.getString("original_queue")); + assertEquals(2, block.getInt("attempts")); + } + + @Test + void poisonTextMessageWithoutABodyIsRecordedNotLoopedOnRecover() throws Exception { + // A TextMessage whose body was never set reads back null: it is poison (empty) and must be dead-lettered + // and acknowledged once, not turned into an NPE that recover()s the session for ever. + TextMessage message = incoming(null, null, 1); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .build() + .poll(); + + verify(dlqProducer).send(any(Queue.class), any(Message.class)); + verify(message).acknowledge(); + verify(session, never()).recover(); + org.json.JSONObject record = new org.json.JSONObject(written.getValue()); + assertEquals("", record.getString("raw")); + assertEquals("poison", record.getJSONObject("dead_letter").getString("reason")); + } + + @Test + void versionGatedTextMessageWithoutABodyIsForwardedAsEmpty() throws Exception { + TextMessage message = incoming(null, URN, 1); + when(message.getStringProperty("bq_schema_version")).thenReturn("2"); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(mock(MessageProducer.class), "orders.dlq") + .build() + .poll(); + + assertEquals("", written.getValue()); + verify(message).acknowledge(); + verify(session, never()).recover(); + } + + @Test + void poisonOriginalQueueFallsBackToTheDlqNameWithoutItsSuffix() throws Exception { + TextMessage message = incoming("plain text", null, 1); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(mock(MessageProducer.class), "orders.dlq") + .build() + .poll(); + + org.json.JSONObject record = new org.json.JSONObject(written.getValue()); + assertEquals("plain text", record.getString("raw")); + assertEquals("orders", record.getJSONObject("dead_letter").getString("original_queue")); + } + + @Test + void poisonWithDeleteStrategyIsAcknowledgedWithoutARecord() throws Exception { + TextMessage message = incoming("not-json", null, 1); + Session session = mock(Session.class); + MessageProducer dlqProducer = mock(MessageProducer.class); + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .unknownUrn(UnknownUrnStrategy.DELETE) + .build() + .poll(); + + verify(dlqProducer, never()).send(any(Queue.class), any(Message.class)); + verify(message).acknowledge(); + } + + @Test + void poisonWithoutDlqIsAcknowledgedAndNeverRetried() throws Exception { + TextMessage message = incoming("not-json", null, 1); + Session session = mock(Session.class); + + ArtemisConsumer.builder(consumerWith(message), session).build().poll(); + + verify(message).acknowledge(); + verify(session, never()).recover(); + } + + @Test + void poisonFailedDlqWriteRecoversTheSessionInsteadOfAcknowledging() throws Exception { + TextMessage message = incoming("not-json", null, 1); + MessageProducer failing = mock(MessageProducer.class); + Session session = sessionWithDlq(failing); + + ArtemisConsumer.builder(consumerWith(message), session).deadLetterQueue(failing, "orders.dlq").build().poll(); + + verify(message, never()).acknowledge(); + verify(session).recover(); + } + + @Test + void versionGatedWithDeleteStrategyIsAcknowledgedWithoutAWrite() throws Exception { + TextMessage message = incoming("not-json", URN, 1); + when(message.getStringProperty("bq_schema_version")).thenReturn("2"); + MessageProducer dlqProducer = mock(MessageProducer.class); + Session session = mock(Session.class); + List errors = new ArrayList<>(); + + ArtemisConsumer.builder(consumerWith(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .unknownUrn(UnknownUrnStrategy.DELETE) + .onError((e, env, m) -> errors.add(e)) + .build() + .poll(); + + verify(dlqProducer, never()).send(any(Queue.class), any(Message.class)); + verify(message).acknowledge(); + assertEquals(1, errors.size()); + } + + @Test + void versionGatedWithDeadLetterStrategyButNoDlqIsAcknowledgedAndDropped() throws Exception { + TextMessage message = incoming("not-json", URN, 1); + when(message.getStringProperty("bq_schema_version")).thenReturn("2"); + Session session = mock(Session.class); + + ArtemisConsumer.builder(consumerWith(message), session).unknownUrn(UnknownUrnStrategy.DEAD_LETTER).build().poll(); + + verify(message).acknowledge(); + verify(session, never()).recover(); + } + @Test void unknownUrnDeadLetterGoesToDlq() throws Exception { TextMessage message = incoming(body(0), URN, 1); @@ -569,4 +752,114 @@ void dlqFailureWithThrowingOnErrorStillRecovers() throws Exception { verify(session).recover(); verify(message, never()).acknowledge(); } + // ---- BytesMessage bodies (AMQP 1.0 Data section): the bytes are never lost ------------------- + + @Test + void validEnvelopeInABytesMessageIsRoutedAndAcknowledged() throws Exception { + jakarta.jms.BytesMessage message = bytesMessage(body(0).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + when(message.getJMSType()).thenReturn(URN); + int[] handled = {0}; + + ArtemisConsumer.builder(consumerWithAny(message), mock(Session.class)) + .handler(URN, (env, m) -> handled[0]++) + .build() + .poll(); + + assertEquals(1, handled[0]); + verify(message).acknowledge(); + } + + @Test + void bytesMessageReachesTheHandlerRewoundToItsStart() throws Exception { + jakarta.jms.BytesMessage message = bytesMessage(body(0).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + when(message.getJMSType()).thenReturn(URN); + org.mockito.InOrder order = org.mockito.Mockito.inOrder(message); + boolean[] rewound = {false}; + + ArtemisConsumer.builder(consumerWithAny(message), mock(Session.class)) + .handler(URN, (env, m) -> { + // The last reset() before the handler runs is the one that rewinds the body for it. + order.verify(message, org.mockito.Mockito.atLeastOnce()).reset(); + rewound[0] = true; + }) + .build() + .poll(); + + assertTrue(rewound[0]); + // reset() both before and after the read. + verify(message, org.mockito.Mockito.times(2)).reset(); + } + + @Test + void poisonBytesMessageThatIsNotUtf8KeepsItsBytesInTheRecord() throws Exception { + byte[] content = {(byte) 0xFF, (byte) 0xFE, 'a'}; + jakarta.jms.BytesMessage message = bytesMessage(content); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + int[] handled = {0}; + + ArtemisConsumer.builder(consumerWithAny(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .handler(URN, (env, m) -> handled[0]++) + .build() + .poll(); + + assertEquals(0, handled[0]); + org.json.JSONObject record = new org.json.JSONObject(written.getValue()); + assertEquals("//5h", record.getString("raw")); + assertEquals("base64", record.getString("raw_encoding")); + assertEquals("poison", record.getJSONObject("dead_letter").getString("reason")); + verify(dlqProducer).send(any(Queue.class), any(Message.class)); + verify(message).acknowledge(); + verify(session, never()).recover(); + } + + @Test + void validEnvelopeWithOneInvalidUtf8ByteIsPoisonNotDecodedLeniently() throws Exception { + String json = EnvelopeCodec.encode(EnvelopeCodec.make(URN, Map.of("note", "ab"), "orders", null)); + byte[] content = json.getBytes(java.nio.charset.StandardCharsets.UTF_8); + content[json.indexOf("ab")] = (byte) 0xFF; + jakarta.jms.BytesMessage message = bytesMessage(content); + when(message.getJMSType()).thenReturn(URN); + Session session = mock(Session.class); + ArgumentCaptor written = ArgumentCaptor.forClass(String.class); + when(session.createTextMessage(written.capture())).thenReturn(mock(TextMessage.class)); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + int[] handled = {0}; + + ArtemisConsumer.builder(consumerWithAny(message), session) + .deadLetterQueue(mock(MessageProducer.class), "orders.dlq") + .handler(URN, (env, m) -> handled[0]++) + .build() + .poll(); + + assertEquals(0, handled[0]); + org.json.JSONObject record = new org.json.JSONObject(written.getValue()); + assertEquals(java.util.Base64.getEncoder().encodeToString(content), record.getString("raw")); + assertEquals("base64", record.getString("raw_encoding")); + } + + @Test + void versionGatedBytesMessageIsForwardedAsBytesExactly() throws Exception { + byte[] content = {'{', (byte) 0xFF, '}'}; + jakarta.jms.BytesMessage message = bytesMessage(content); + when(message.getStringProperty("bq_schema_version")).thenReturn("2"); + Session session = mock(Session.class); + jakarta.jms.BytesMessage forwarded = mock(jakarta.jms.BytesMessage.class); + when(session.createBytesMessage()).thenReturn(forwarded); + when(session.createQueue("orders.dlq")).thenReturn(mock(Queue.class)); + MessageProducer dlqProducer = mock(MessageProducer.class); + + ArtemisConsumer.builder(consumerWithAny(message), session) + .deadLetterQueue(dlqProducer, "orders.dlq") + .build() + .poll(); + + verify(forwarded).writeBytes(content); + verify(dlqProducer).send(any(Queue.class), org.mockito.ArgumentMatchers.eq(forwarded)); + verify(message).acknowledge(); + } } diff --git a/src/test/java/com/babelqueue/artemis/JmsMessagesTest.java b/src/test/java/com/babelqueue/artemis/JmsMessagesTest.java index d7806b2..dd29dca 100644 --- a/src/test/java/com/babelqueue/artemis/JmsMessagesTest.java +++ b/src/test/java/com/babelqueue/artemis/JmsMessagesTest.java @@ -13,8 +13,48 @@ class JmsMessagesTest { @Test - void textOfNonTextMessageIsEmpty() throws Exception { - assertEquals("", JmsMessages.text(mock(Message.class))); + void bodyOfNeitherTextNorBytesMessageIsEmpty() throws Exception { + assertEquals(0, JmsMessages.bodyBytes(mock(Message.class)).length); + } + + @Test + void bodyBytesOfATextMessageAreItsUtf8AndEmptyWhenUnset() throws Exception { + jakarta.jms.TextMessage text = mock(jakarta.jms.TextMessage.class); + when(text.getText()).thenReturn("caf\u00e9"); + org.junit.jupiter.api.Assertions.assertArrayEquals( + "caf\u00e9".getBytes(java.nio.charset.StandardCharsets.UTF_8), JmsMessages.bodyBytes(text)); + jakarta.jms.TextMessage unset = mock(jakarta.jms.TextMessage.class); + assertEquals(0, JmsMessages.bodyBytes(unset).length); + } + + @Test + void bodyBytesOfABytesMessageAreTheExactContentIncludingInvalidUtf8() throws Exception { + byte[] content = {'{', (byte) 0xFF, (byte) 0xFE, '}'}; + jakarta.jms.BytesMessage bytes = ArtemisConsumerTest.bytesMessage(content); + org.junit.jupiter.api.Assertions.assertArrayEquals(content, JmsMessages.bodyBytes(bytes)); + // reset() before the read and again after it: the message is left readable from the start. + org.mockito.Mockito.verify(bytes, org.mockito.Mockito.times(2)).reset(); + } + + @Test + void shortReadsAreAssembledAndAnEarlyEndIsTruncated() throws Exception { + jakarta.jms.BytesMessage bytes = mock(jakarta.jms.BytesMessage.class); + when(bytes.getBodyLength()).thenReturn(4L); + when(bytes.readBytes(org.mockito.ArgumentMatchers.any(byte[].class))).thenAnswer(inv -> { + byte[] target = inv.getArgument(0); + target[0] = 'a'; + if (target.length == 4) { + return 1; // short read + } + return -1; // premature end of stream + }); + org.junit.jupiter.api.Assertions.assertArrayEquals(new byte[] {'a'}, JmsMessages.bodyBytes(bytes)); + } + + @Test + void strictUtf8RejectsInvalidBytesWithoutSubstitution() { + assertEquals("ok", JmsMessages.strictUtf8("ok".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertNull(JmsMessages.strictUtf8(new byte[] {(byte) 0xFF})); } @Test diff --git a/src/test/java/com/babelqueue/artemis/PoisonRecordChecks.java b/src/test/java/com/babelqueue/artemis/PoisonRecordChecks.java new file mode 100644 index 0000000..43e97ac --- /dev/null +++ b/src/test/java/com/babelqueue/artemis/PoisonRecordChecks.java @@ -0,0 +1,131 @@ +package com.babelqueue.artemis; + +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",