diff --git a/CHANGELOG.md b/CHANGELOG.md index cd08e9a..aa06a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.14.0] - 2026-10-26 + +### Added +- `PoisonRecord.build(body, originalQueue, attempts, error, exception)` (ADR-0035): builds the poison `dead_letter` + block itself (`reason`, `error`, `exception`, `failed_at`, `original_queue`, `attempts`, `lang`) and delegates to + `build(body, deadLetter)`, so transports no longer hand-build it. + ## [1.13.0] - 2026-10-24 ### Added diff --git a/pom.xml b/pom.xml index 3cff2b6..7c50ccc 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.babelqueue babelqueue-core - 1.13.0 + 1.14.0 jar BabelQueue Core diff --git a/src/main/java/com/babelqueue/PoisonRecord.java b/src/main/java/com/babelqueue/PoisonRecord.java index 7c61220..7c3082e 100644 --- a/src/main/java/com/babelqueue/PoisonRecord.java +++ b/src/main/java/com/babelqueue/PoisonRecord.java @@ -5,6 +5,8 @@ import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; /** * Builds the DLQ record of a poison body (ADR-0035, error-handling.md section 6). @@ -16,6 +18,10 @@ * "dead_letter": ...}}: {@code raw} is the body as plain text when it is valid UTF-8, otherwise the standard padded * base64 of the exact bytes with {@code "raw_encoding": "base64"}. U+FFFD substitution never happens. * + *

Transports should call {@link #build(byte[], String, int, String, String)}: it assembles the ADR-0035 + * {@code dead_letter} block itself (so no transport hand-builds it) and delegates here. The two-argument + * {@link #build(byte[], String)} stays for callers that already hold the block JSON. + * *

The strict scan is iterative (no recursion), so a body nested thousands of levels deep cannot overflow the stack. */ public final class PoisonRecord { @@ -23,6 +29,35 @@ public final class PoisonRecord { private PoisonRecord() { } + /** + * Builds the DLQ record of a poison body, assembling the ADR-0035 {@code dead_letter} block itself. Transports + * should use this instead of hand-building the block. + * + *

The block members are written in this order: {@code reason} ({@code "poison"}), {@code error}, + * {@code exception}, {@code failed_at} (epoch milliseconds, taken now), {@code original_queue}, {@code attempts} + * and {@code lang} ({@link EnvelopeCodec#SOURCE_LANG}). A {@code null} {@code error} or {@code exception} is + * written as JSON {@code null}, not omitted. The block is then spliced or wrapped by + * {@link #build(byte[], String)}. + * + * @param body the exact record value bytes + * @param originalQueue the queue the body was consumed from + * @param attempts informational delivery attempts so far + * @param error the failure message, or {@code null} + * @param exception the failure type name, or {@code null} + * @return the DLQ record JSON text + */ + public static String build(byte[] body, String originalQueue, int attempts, String error, String exception) { + Map block = new LinkedHashMap<>(); + block.put("reason", "poison"); + block.put("error", error); + block.put("exception", exception); + block.put("failed_at", System.currentTimeMillis()); + block.put("original_queue", originalQueue); + block.put("attempts", attempts); + block.put("lang", EnvelopeCodec.SOURCE_LANG); + return build(body, JsonValues.encode(block)); + } + /** * @param body the exact record value bytes * @param deadLetter the JSON text of the {@code dead_letter} block: exactly one strict JSON object, spliced into diff --git a/src/test/java/com/babelqueue/PoisonRecordTest.java b/src/test/java/com/babelqueue/PoisonRecordTest.java new file mode 100644 index 0000000..f1c4f96 --- /dev/null +++ b/src/test/java/com/babelqueue/PoisonRecordTest.java @@ -0,0 +1,129 @@ +package com.babelqueue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PoisonRecordTest { + + private static final List BLOCK_ORDER = + List.of("reason", "error", "exception", "failed_at", "original_queue", "attempts", "lang"); + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + @SuppressWarnings("unchecked") + private static Map parseObject(String json) { + return (Map) assertInstanceOf(Map.class, Json.parse(json)); + } + + /** Rebuilds the block by hand with the failed_at taken from the produced record, then compares bytes. */ + private static void assertBytesEqualToManualBlock(byte[] body, String queue, int attempts, String error, + String exception) { + long before = System.currentTimeMillis(); + String actual = PoisonRecord.build(body, queue, attempts, error, exception); + long after = System.currentTimeMillis(); + + Map dl = deadLetterOf(actual); + Object failedAt = dl.get("failed_at"); + long ts = assertInstanceOf(Number.class, failedAt).longValue(); + assertTrue(ts >= before && ts <= after, "failed_at is epoch ms taken during the call"); + + Map block = new LinkedHashMap<>(); + block.put("reason", "poison"); + block.put("error", error); + block.put("exception", exception); + block.put("failed_at", ts); + block.put("original_queue", queue); + block.put("attempts", attempts); + block.put("lang", EnvelopeCodec.SOURCE_LANG); + assertEquals(PoisonRecord.build(body, JsonValues.encode(block)), actual); + } + + private static Map deadLetterOf(String record) { + Object dl = parseObject(record).get("dead_letter"); + @SuppressWarnings("unchecked") + Map map = (Map) assertInstanceOf(Map.class, dl); + return map; + } + + @Test + void overloadEqualsManuallyBuiltBlock() { + assertBytesEqualToManualBlock(bytes("{\"a\":1}"), "orders", 3, "boom", "java.lang.IllegalStateException"); + } + + @Test + void blockHasCanonicalOrderAndTypes() { + String record = PoisonRecord.build(bytes("{\"a\":1}"), "orders", 2, "boom", "X"); + Map dl = deadLetterOf(record); + assertEquals(BLOCK_ORDER, new ArrayList<>(dl.keySet())); + assertEquals("poison", dl.get("reason")); + assertEquals("boom", dl.get("error")); + assertEquals("X", dl.get("exception")); + assertEquals("orders", dl.get("original_queue")); + assertEquals(2L, ((Number) dl.get("attempts")).longValue()); + assertEquals("java", dl.get("lang")); + assertTrue(dl.get("failed_at") instanceof Number); + assertTrue(((Number) dl.get("failed_at")).longValue() > 1_600_000_000_000L); + } + + @Test + void nullErrorAndExceptionAreWrittenAsNull() { + String record = PoisonRecord.build(bytes("{\"a\":1}"), "q", 0, null, null); + Map dl = deadLetterOf(record); + assertEquals(BLOCK_ORDER, new ArrayList<>(dl.keySet())); + assertTrue(dl.containsKey("error")); + assertTrue(dl.containsKey("exception")); + assertNull(dl.get("error")); + assertNull(dl.get("exception")); + assertTrue(record.contains("\"error\":null,\"exception\":null")); + assertBytesEqualToManualBlock(bytes("{\"a\":1}"), "q", 0, null, null); + } + + @Test + void zeroAttempts() { + String record = PoisonRecord.build(bytes("{}"), "q", 0, "e", "E"); + assertEquals(0L, ((Number) deadLetterOf(record).get("attempts")).longValue()); + assertBytesEqualToManualBlock(bytes("{}"), "q", 0, "e", "E"); + } + + @Test + void invalidUtf8TakesBase64Branch() { + byte[] body = {(byte) 0xFF, (byte) 0xFE, 0x41}; + String record = PoisonRecord.build(body, "q", 1, "bad", "E"); + Map parsed = parseObject(record); + assertEquals(Base64.getEncoder().encodeToString(body), parsed.get("raw")); + assertEquals("base64", parsed.get("raw_encoding")); + assertEquals(BLOCK_ORDER, new ArrayList<>(deadLetterOf(record).keySet())); + assertBytesEqualToManualBlock(body, "q", 1, "bad", "E"); + } + + @Test + void spliceableObjectIsSplicedAtByteLevel() { + String body = "{\"n\":1e400,\"big\":18446744073709551616,\"n\":2}"; + String record = PoisonRecord.build(bytes(body), "q", 4, "e", "E"); + assertTrue(record.startsWith(body.substring(0, body.length() - 1) + ",\"dead_letter\":{"), record); + assertTrue(record.endsWith("}}")); + assertBytesEqualToManualBlock(bytes(body), "q", 4, "e", "E"); + } + + @Test + void nonObjectBodyIsWrappedAsRawText() { + String record = PoisonRecord.build(bytes("not json"), "q", 1, "e", "E"); + Map parsed = parseObject(record); + assertEquals("not json", parsed.get("raw")); + assertFalse(parsed.containsKey("raw_encoding")); + assertBytesEqualToManualBlock(bytes("not json"), "q", 1, "e", "E"); + } +}