Redis transport

Last updated:

com.babelqueue:babelqueue-redis is a Redis transport on the Java core, built on the Lettuce client. It sends the canonical envelope as a Redis list element with the §1 reliable-queue pattern, and consumes by routing each message to a handler by URN — so a message it produces is consumed by any other BabelQueue SDK, and vice-versa.

Redis lists carry no native metadata, so — unlike the SQS, RabbitMQ or Kafka bindings — there is no property projection. By default the list element is the canonical envelope JSON, byte-for-byte, with no wrapping and no added fields; routing and tracing read the body directly. When headers are published (publishWithHeaders, e.g. a W3C traceparent per ADR-0028), the element is instead a __bq_frame wrapper {"__bq_frame":1,"headers":…,"body":<envelope>} that every consumer unwraps; the envelope inside is still unchanged.

Install

Maven:

<dependency>
  <groupId>com.babelqueue</groupId>
  <artifactId>babelqueue-redis</artifactId>
  <version>1.1.0</version>
</dependency>

Requirements: Java 17+. It pulls babelqueue-core and io.lettuce:lettuce-core transitively.

The consumer reserves with a Lua script that issues LMOVE, so it needs Redis 6.2+. On Redis Cluster, use hash-tagged queue names; see Redis compatibility.

Produce

import com.babelqueue.redis.RedisPublisher;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.sync.RedisCommands;
import java.util.Map;

RedisClient client = RedisClient.create("redis://localhost:6379");
RedisCommands<String, String> redis = client.connect().sync(); // the command seam

String id = RedisPublisher.create(redis, "orders")
    .publish("urn:babel:orders:created", Map.of("order_id", 1042L));

publish(urn, data) returns the message meta.id; an overload adds a traceId to continue a trace. Produce is RPUSH <queue> <envelope>.

Consume

import com.babelqueue.redis.RedisConsumer;

RedisConsumer consumer = RedisConsumer.builder(redis, "orders")
    .handler("urn:babel:orders:created", (env, body) -> {
        // env.data(), env.traceId(), env.attempts() ...
    })
    .onError((err, env, body) -> log.warn("bad message", err))
    .build();

consumer.run(); // sleep-polls until the thread is interrupted

A reserve atomically moves the head of the queue onto a per-queue <queue>:processing list (a Lua script that issues LMOVE <queue> <queue>:processing LEFT RIGHT, polling with a 100 ms to 1 s backoff because Lua cannot block), so an in-flight message survives a crash; a successful handler LREMs it. Retry is at-least-once: a throwing handler leaves the element on the processing list until the recovery sweep requeues it after retry_after. The poll loop never stops on a bad message — observe via onError / onUnknownUrn. An unknown URN is passed to onUnknownUrn (then acked) if one is set; otherwise it is reported to onError and dead-lettered to <queue>.dlq with dead_letter.reason: "unknown_urn" (a fixed dead_letter, a documented deviation from the generic fail default). The DLQ write is unconditional: there is no DLQ opt-in switch, so it never degrades to delete.

The command seam is Lettuce’s RedisCommands<String, String> interface, so the publisher and consumer unit-test against a mock with no Redis and no network.

Recovery and the zset layout

The default (RedisLayout.LEGACY) layout writes a deadline member to <queue>:processing:deadlines on every reserve and sweeps expired ones back to the ready list before the next reserve. The window is retry_after, 90 seconds by default: retryAfterSeconds(long) sets it and retryAfterDisabled() is retry_after: null (see the +inf note). Keep the handler timeout shorter than retry_after. A legacy recovery does not increment attempts, and the legacy layout has no delayed set: a delayed release is rejected there.

For Laravel’s sorted-set layout, set .layout(RedisLayout.ZSET) on both the publisher and the consumer builder. It adds :delayed, :reserved and :notify, delayed publishes, and recovery that counts as an attempt. See Redis in the binding spec.

Contract mapping (§1)

Envelope Redis
body the list element — the canonical envelope JSON, byte-for-byte, no wrapping (a __bq_frame wrapper only when publishWithHeaders carries headers)
job (URN) read from the body and routed consumer-side (Redis lists carry no native metadata)
produce RPUSH <queue> <envelope>
reserve Lua script: LMOVE <queue> <queue>:processing LEFT RIGHT plus a deadline member (head → tail; crash-safe in-flight)
ack LREM <queue>:processing 1 <envelope>
attempts taken from the body unchanged (Redis has no native delivery counter)

This is a Java-owned reliable queue, mirroring the Go runtime’s reliable-queue mechanism — pointed at a queue this SDK owns end-to-end it is a complete, crash-safe transport. Full parity with Laravel’s reserved-sorted-set reservation on a shared PHP+Java Redis queue is a separate task (see broker-bindings §1.4). The envelope is unchanged (schema_version stays 1); Redis is purely additive.