All notable changes to com.babelqueue:babelqueue-kafka are documented here.
The format is based on Keep a Changelog, and
this project adheres to Semantic Versioning.
The envelope wire format is versioned separately by meta.schema_version
(currently 1) — see the contract at babelqueue.com.
- Unknown top-level keys and unknown
metakeys (ADR-0031 extension keys, e.g.extra_top,meta.vendor_flag) are no longer lost when a message is republished to a retry topic: the attempt bump now usesEnvelope.withAttempts, which carries the extras, instead of rebuilding the envelope through the 6-argument constructor. The dead-letter copy already preserved them; regression tests now lock both paths. KafkaRedriveTransportno longer treats a job made only of Unicode whitespace (e.g. U+2003) as blank when deciding whether to project thebq-*headers: blank is the closed ASCII set (space, tab, LF, VT, FF, CR) of ADR-0031 (c), so it matches the other SDKs. The check is local to the transport, so it does not depend on the resolved core version.
- Docs only: the note in the previous release that the Go reference's case-insensitive envelope-key read "is tightened separately" is now closed — Go core 1.14.0 reads envelope keys exactly (case-sensitive, last duplicate wins), so every SDK now decides poison on the same exact-case keys.
- The
babelqueue-coredependency is raised to 1.11.0, so the ADR-0031 decode behaviour (job+urnwarning, closed ASCII blank set) and encode-time extension-key rejection come in transitively.
- An undecodable (poison) body is now dead-lettered the way the Go reference does (§6.5) instead of as an
annotated empty envelope that lost the original body: a body that is a single JSON object keeps its keys, anything
else (non-JSON, a JSON scalar/array, trailing tokens, an empty value; invalid UTF-8 is stored with U+FFFD
substitutions) is wrapped as
{"raw": "<body>"}, and adead_letterblock withreason: "poison"is attached (error/exceptionfrom the decode failure,original_queue= the consumed topic,attemptsfrom thebq-attemptsheader,lang: "java"). Behaviour change - the poison trigger set is now exactly Go's: a record is poison when, and only when, the Go reference's typedbabelqueue.Decodewould reject it -data/meta/dead_letternot an object;job,trace_id,meta.id|queue|langordead_letter.reason|error|exception|original_queue|langnot a string;meta.schema_version,meta.created_at,attemptsordead_letter.failed_at|attemptsnot an integer that fits int64 (so a string such as"2026", a fraction, an exponent such as1e2, or an out-of-range value is poison); a number indatathat is not a finite double (1e400); duplicate keys are each type-checked and member names are matched exactly as spelled (case-sensitive, as the contract schema and the core decoder read them;JOBorMetais an unknown member); and a malformed body (leading zeros,NaN, trailing tokens, a BOM, a second object). A JSONnullbody is not poison (empty envelope, unknown-URN path); a non-stringurnalias is ignored. Previously only a top-level subset was checked. A well-typed but non-conformant envelope (missingdata, blanktrace_id, a bodyschema_versionother than 1) is no longer dead-lettered byEnvelopeCodec.accepts: like Go, which never callsAcceptson consume, it is routed by its URN to the handler (an empty URN takes the unknown-URN path). The record key is carried unchanged and the offset is committed only after the DLQ write is confirmed (otherwise the partition is rewound, as before). The §6.7 schema-version-gate rejection still forwards the record raw. - Poison DLQ records: a body holding a number the codec cannot write back (e.g.
1e400) is carried verbatim as{"raw": ...}instead of failing the DLQ write, andKafkaHeaders.ofno longer projectsbq-schema-version: 0when the body has nometa.schema_version(a later §6.7 gate would have rejected the DLQ record). Remaining, intentional differences from the Go reference for a poison record:dead_letter.attemptsis thebq-attemptsheader (Go writes 0) andoriginal_queueis thebq-original-topicheader when present (Go writes the consumed topic); the DLQ record also carriesbq-original-topicplus thebq-*headers projected from the body (Go adds no headers); and a float literal such as1.0/1e2is re-formatted where the body is re-encoded. The wire envelope and thedead_lettershape are unchanged. - Any decode failure is poison; the partition is never left to wedge. The "exactly Go's" trigger set above is corrected in two respects. (1) Any
RuntimeExceptionorStackOverflowErrorfrom the shape scan, the codec decode or the poison wrapper is dead-lettered as poison (a deeply nesteddatapreviously overflowed the recursive scanner, escapedpoll, left the offset uncommitted and locked the partition). The nesting the recursive scanner/parser carries is bounded by the thread's stack (measured at roughly 3000-5000 levels, depending on the thread's stack size), where Go allows 10000; deeper bodies are poison. (2) Member names are matched case-sensitively, exactly as the core decoder reads them (noencoding/json-style folding, noequalsIgnoreCase); the Go reference's case-insensitive read contradicts the contract schema and is tightened separately. The wire envelope and thedead_lettershape are unchanged.
KafkaRedriveTransportnow implementsRedrive.Rewinder: wheneverRedrive.redrivestops on a failed message (a failed restore write back to the DLQ, a failed redrive write, a throwingselect, a failed ack), the consumer isseek-ed back to the first unacknowledged record of every partition it has handed out in the batch (Redrive.redrivepops the whole batch before processing it), so a later cumulative commit can no longer skip any of them. A failingseekdoes not stop the others (every partition is attempted) and is reported together with the original error. An acknowledged record is never rewound: after a failed redrive write whose restore succeeded, only the rest of the batch is (the next record in the same partition resumes right after it). Requires ababelqueue-corethat providesRedrive.Rewinder.
KafkaRedriveTransport: aRedrive.Transportover Apache Kafka (a subscribed DLQConsumer+ aProducer) soRedrive.redrive(ADR-0026) can replay<topic>.dlqback to the work topic, committing the DLQ offset only after the re-publish is acknowledged. It implements the core'sRedrive.KeyedRepublisher, so the redrive write and the restore write back to the DLQ carry the DLQ record's key byte-for-byte (null stays null, empty stays empty, none is synthesised; contract §6.5). Thebq-headers are re-projected from the body being published. The replay-bypass header is not propagated by this transport. A restore write (dry run, unselected, undecodable, failed redrive write) re-writes the DLQ record verbatim: its original value bytes (a null value stays null; a value that is not valid UTF-8 is never altered), headers and key, with a fresh timestamp. Requiresbabelqueue-core1.9.0, which shipsRedrive.KeyedRepublisher;babelqueue-core.versionis pinned to it.
KafkaConsumerno longer lets a throwingonErrorcallback bypass settlement. PreviouslyonErrorran before the raw DLQ write + commit on the undecodable-body and non-conformant-envelope paths, and before the retry republish / DLQ write + commit on the handler-failure path, so a throwing callback skipped the settlement and the record was redelivered forever. These paths now settle first and report afterwards (also when settling fails), andreport()swallows callback exceptions, so the commit / seek-back already performed stands and the poll loop keeps running. Behaviour note: a throwingonErroron the schema-version-gate and rewind paths is now swallowed instead of propagating out ofpoll(). No public API change; the envelope, thedead_letterblock and the headers are unchanged (schema_versionstays 1). Patch-level behaviour fix.
- The original Kafka record key is now preserved when the consumer republishes a record: the retry-tier write, the
unknown-URN release, and the envelope DLQ write all carried a
nullkey before. A null key stays null, an empty key stays empty and no key is synthesised; the raw/poison DLQ already kept it. Partition equality is not guaranteed. The envelope, thedead_letterblock and the headers are unchanged (schema_versionstays 1). Patch-level behaviour fix.
KafkaConsumerno longer loses a record when a retry-tier / DLQ write fails on the non-gate paths (handler failure -> retry tier or max-tries<topic>.dlq, undecodable body, non-conformant envelope, unmapped URN underdead_letter/release). The write was fire-and-forget and the offset was committed regardless; it is now awaited and the offset is committed only after the broker confirmed it. On failure nothing is committed, the partition is rewound (seek) to the failed record, the rest of that partition's batch is skipped and the failure is reported viaonError- the same protection the §6.7 gate already had.
KafkaConsumernow applies the §6.7 schema-version gate before decoding the body: when thebq-schema-versionheader is present and is not the supported version (1), the record is never decoded and the handler is never called; it is reported viaonError(with anullenvelope), its raw bytes are forwarded to<topic>.dlq(when configured) and the offset is committed so the partition advances. A missing or blank header (empty or ASCII-whitespace-only) keeps the existing decode path. Cross-SDK parity (GR-5): the header is read as UTF-8 and compared exactly, with no trimming. The gate waits for the DLQ write to be confirmed before committing; if it fails, nothing is committed, the partition is rewound (seek) to the rejected record, the rest of that partition's batch is skipped and the failure goes toonError, so the record is redelivered and rejected again instead of being lost.- Build: removed a duplicate
org.json:jsontest dependency frompom.xmlthat made Maven reject the POM.
- Bump
org.apache.kafka:kafka-clientsfrom 3.9.1 to 3.9.2 (security fix).
- Initial release. An Apache Kafka transport on
babelqueue-core+org.apache.kafka:kafka-clients, implementing §6 of the broker-bindings contract. Kafka has no native ack/delay/DLQ/delivery-counter, so the binding absorbs all four:KafkaPublisher(value = canonical envelope, record timestamp =meta.created_at, thebq-header projection —bq-jobroutes; a delay routes to a<topic>.retry.<n>tier or raises without one) andKafkaConsumer(process-then-commit manual-commit consume; a throwing handler republishes to the next retry tier withbq-attempts + 1then commits; terminal failures go to<topic>.dlqwith the additivedead_letterblock; thebq-attemptsheader is the authoritative counter with the body as fallback;fail/delete/release/dead_letterunknown-URN strategies; poison bodies forwarded to the DLQ).RetryTopicsconfigures the tiered retry/delay topology. Java 17, JUnit 5, JaCoCo ≥90% line coverage; the KafkaConsumeris mocked with Mockito and the producer with the officialMockProducer(no Kafka, no network). The envelope is unchanged (schema_version: 1); Apache Kafka is purely additive.