redis

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 13 Imported by: 0

README

BabelQueue Redis transport (Go)

Go Reference

A Redis-backed babelqueue.Transport for the BabelQueue Go runtime, using the reliable-queue pattern (RPUSH to produce, BLMOVE head → per-queue processing list to reserve, LREM to ack). Built on go-redis.

go get github.com/babelqueue/babelqueue-go/redis
import (
    babelqueue "github.com/babelqueue/babelqueue-go"
    "github.com/babelqueue/babelqueue-go/redis"
)

tr, err := redis.New("redis://localhost:6379/0")
if err != nil { panic(err) }
defer tr.Close()

app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))
app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
    // handle env.Data ...
    return nil
})
app.Publish(ctx, "urn:babel:orders:created", map[string]any{"order_id": 1042})
app.Consume(ctx)

Bring your own client with redis.NewWithClient(client) (custom pool / TLS / auth). The BabelQueue core stays zero-dependency; this module is the only place go-redis is pulled in.

Laravel-compatible layout, delays and recovery (ADR-0033)

redis.WithLaravelLayout(prefix) puts the transport on the shared Laravel keys (<q> ready list, <q>:delayed / <q>:reserved sorted sets, <q>:notify wake-up list). It is opt-in; the default layout stays the Go-owned reliable queue below.

  • Reserve is Laravel's pop script: the handler receives the raw head byte for byte and the attempt count it sees is job.attempts+1. Ack is ZREM of the :reserved member.
  • Compatibility exception (ADR-0033, to ADR-0031): the member Laravel's pop script writes to :reserved is re-encoded by cjson, so it is not the stored bytes. That is Laravel's behaviour and the only place a Redis value is not preserved byte for byte. Release and delayed publish use the SDK codec, never cjson, and keep unknown envelope keys with their values and types.
  • PublishDelayed(ctx, queue, env, delay) is one ZADD <q>:delayed floor(now)+ceil(delay) payload (no notify token). Release(ctx, msg, delay) is one script: ZREM :reserved plus ZADD :delayed with attempts+1. There is no push-and-ack fallback.
  • Undecodable bodies (poison, binding section 1.1): a head the reserve script flags ({job, job} answer) and a body the core codec rejects (string meta/data, numeric trace_id, fractional attempts) are flagged Poison, and so is an identity-less body (neither a valid job nor a valid urn; ADR-0036 decision 4, binding section 1.1 "Identity-less body"): it is no unknown URN, so on_unknown_urn: delete and a disabled dead-letter have no effect on it. The App writes the raw body, untouched, to <q>.dlq (ADR-0035 record, reason: "poison") through the reservation-gated DeadLetter, on the first delivery and in every configuration (also with dead-lettering off, any retry strategy). Release returns ErrReleaseUnsupported for such a body and never re-encodes it, so it is never lost and never loops.
  • Migration runs before every pop: due :delayed members first, then expired :reserved members (score <= floor(now)), WithMigrationBatchSize(n) bounds one migrate (default: unbounded). An expired :reserved member goes back to <q> and counts as an attempt.
  • retry_after defaults to 90 s (WithRetryAfter(d)); WithoutRetryAfter() is retry_after: null (this consumer never recovers expired reservations). A negative value is a configuration error.
  • Every blocking wait is sliced to at most 1 s with a migrate before each slice, so a delayed message is picked up within about a second of becoming due.
  • No traceparent in this mode (A3). The __bq_frame below is not written: PublishWithHeaders drops the headers and stores the bare envelope, because Laravel workers cannot unwrap a frame. trace_id stays in the envelope; the W3C traceparent header is not carried across a Laravel-layout queue.

Legacy mode: :processing list with deadlines (ADR-0033 section 9)

The default (non-Laravel) layout keeps <q>:processing as its in-flight list and adds a deadlines sorted set <q>:processing:deadlines whose members are <32 lowercase hex token>:<raw envelope> scored floor(now)+retry_after (+inf for retry_after: null). The token comes from crypto/rand only.

  • Reserve is LMOVE <q> <q>:processing LEFT RIGHT plus the ZADD, in one script.
  • Ack, release and dead-letter are gated on ZREM of the deadline member: when it returns 0 the reservation had already been swept, nothing else is touched and a "reservation expired" warning is logged, so a late ack can never remove another worker's identical copy.
  • A sweep script runs before every reserve (also for a retry_after: null consumer): an expired item is LREM'd from :processing (count 1) and pushed back to <q> unchanged. Recovery keeps the bytes and does not increment attempts. Malformed members and orphans are only removed from the deadlines set.
  • When the queue is empty the reserve sleep-polls with a 100 ms backoff doubling up to 1 s, reset when a message arrives and never beyond the pop timeout.
  • Undecodable bodies (poison, binding section 1.1): a body the core codec rejects (not JSON, wrong field types) is flagged Poison on delivery. The App writes the raw body to <q>.dlq through the deadline-gated DeadLetter, on the first delivery and in every configuration (also with dead-lettering off, any retry strategy), so it is never lost and never redelivered. An identity-less body (neither a valid job nor a valid urn) is poison too (ADR-0036 decision 4), also under on_unknown_urn: delete. Release returns ErrReleaseUnsupported for an undecodable body (this layout has no delivery counter, so a verbatim re-queue would loop forever); the App does not reach that branch for a flagged body.

Out-of-band headers (traceparent, ADR-0028)

Default layout only: the Laravel-compatible layout writes the bare envelope and drops headers (A3).

The transport implements the optional babelqueue.HeaderPublisher capability, so out-of-band transport headers — e.g. a W3C traceparent for cross-hop span linkage — ride beside the frozen wire envelope, never inside it. Redis stores only the raw list value (the LREM ack handle is that value), so there is no native per-message metadata channel; the transport instead owns a small JSON frame, distinct from the wire envelope:

{"__bq_frame":1,"headers":{"traceparent":"00-…-01"},"body":"<raw wire envelope>"}
  • Publish stores the bare envelope byte-for-byte (no frame).
  • PublishWithHeaders writes a frame only when the header map is non-empty; with no usable headers it degrades to a byte-identical bare Publish.
  • Pop detects frame-vs-bare by the reserved __bq_frame sentinel (the frozen envelope never carries it). A frame is unwrapped — Body is the raw envelope, Headers carries the frame's headers; the Handle stays the stored frame, so the reliable-queue RPUSH/BLMOVE/LREM semantics are untouched. A bare value (an older or non-otel publisher, or a cross-version queue) consumes exactly as before: Headers is nil and the handle is the bare value.

Full spec: babelqueue.com · MIT © Muhammet Şafak

Documentation

Overview

Package redis is a Redis-backed babelqueue.Transport for the BabelQueue Go runtime. It uses the reliable-queue pattern: RPUSH to produce, BLMOVE the head to a per-queue processing list to reserve (so an in-flight message survives a worker crash), and LREM from that list to acknowledge.

tr, _ := redis.New("redis://localhost:6379/0")
app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))

This default layout is a Go-owned reliable queue. For a *shared* queue with Laravel workers, WithLaravelLayout switches the key names and the reservation to the broker-bindings.md §1.1 layout (see "Laravel-compatible layout" below).

It also implements the optional babelqueue.HeaderPublisher capability: out-of-band transport headers (e.g. a W3C traceparent for cross-hop span linkage, ADR-0028, or the bq-replay-bypass marker, ADR-0027) ride beside the frozen envelope (GR-1) — never in it — and are surfaced back to the consumer on babelqueue.ReceivedMessage.Headers.

Redis stores only the raw list value (the LREM ack handle *is* that value), so — unlike AMQP headers or SQS MessageAttributes — there is no native per-message metadata channel. To carry headers the transport owns a tiny JSON *frame* distinct from the wire envelope:

{"__bq_frame":1,"headers":{"traceparent":"00-..."},"body":"<raw wire envelope>"}

RPUSH stores the frame, so the LREM ack handle stays byte-for-byte what was pushed and the reliable-queue semantics (RPUSH/BLMOVE/LREM/processing list) are untouched. Framing is opt-in and backward compatible: only PublishWithHeaders with a non-empty map writes a frame; plain Publish (and PublishWithHeaders with no headers) stores the bare envelope byte-for-byte, exactly as before. Pop detects frame-vs-bare by the reserved "__bq_frame" sentinel — a frozen wire envelope can never carry it — and a bare value consumes with Headers=nil and Handle=value, so cross-version queues interoperate.

Laravel-compatible layout

WithLaravelLayout puts the transport on the key names of broker-bindings.md §1.1, `<prefix><name>` (ready list), `:delayed` and `:reserved` (sorted sets) and `:notify` (blocking-pop wake-up), with `queues:` as the Laravel default prefix. Transport.Keys returns them. Publish pushes to the ready list and signals the notify list.

Pop first migrates due `:delayed` members and then expired `:reserved` members back to the ready list (score <= floor(now)), then runs Laravel's pop script: it LPOPs the head, decodes it with cjson (under pcall), increments `attempts`, writes the re-encoded member to `:reserved` scored floor(now)+retry_after and consumes one notify token. The handler receives the raw head byte for byte; the attempt count it sees is job.attempts+1 (DeliveryCount). Ack is ZREM of the `:reserved` member. A head that cjson cannot decode, or whose `attempts` is not a number, is written to `:reserved` raw and delivered as poison (A1): the App writes it to the DLQ and acknowledges it, so nothing is lost and nothing stays in `:reserved`.

The re-encoded `:reserved` member is the documented Laravel compatibility exception (ADR-0033) to the lossless re-emit rule of ADR-0031: only the Laravel pop script touches the payload with cjson. The SDK itself always re-emits through its codec, which preserves unknown keys.

Transport.Release (A2) moves the reservation to `:delayed` in one script — ZREM from `:reserved`, ZADD to `:delayed` at floor(now)+ceil(delay) with the SDK-encoded body (attempts+1, unknown keys preserved). Transport.PublishDelayed schedules a message the same way (no notify token: the migrate writes one per moved item). A reservation that is never acknowledged is recovered once its deadline passes and counts as an attempt; retry_after defaults to 90 s, WithoutRetryAfter disables recovery and a negative WithRetryAfter is a configuration error. Every blocking wait is sliced to at most one second with a migrate before each slice, so a delayed message that falls due while a consumer waits is delivered within one second.

There is no out-of-band header channel in this layout (A3): PublishWithHeaders drops the headers and writes the bare envelope, so a traceparent is not carried through Redis lists here (trace_id stays in the envelope, GR-4). A "__bq_frame" value is never written.

Default (legacy) layout recovery

The default `<queue>` + `<queue>:processing` layout keeps its names. Each reservation also writes a deadline member `<token>:<raw>` (token: 32 lowercase hex from crypto/rand) to the `<queue>:processing:deadlines` sorted set, scored floor(now)+retry_after (+inf when WithoutRetryAfter). A sweep before every reserve moves expired members back to the ready list (body and attempts unchanged); ack, release and dead-letter are gated on removing the reservation's own deadline member, so a late ack can never delete another consumer's copy. Items without a deadline member (reserved by an older version) are never touched.

Index

Constants

View Source
const DefaultKeyPrefix = "queues:"

DefaultKeyPrefix is Laravel's default Redis queue key prefix (`queues:`).

View Source
const DefaultMigrationBatchSize = -1

DefaultMigrationBatchSize means "move every due member" (-1), the Laravel behaviour.

View Source
const DefaultRetryAfter = 90 * time.Second

DefaultRetryAfter is the reservation window (Laravel's `retry_after` default, 90 s): a reserved member whose deadline has passed is made ready again.

Variables

View Source
var ErrDelayedUnsupported = errors.New("babelqueue/redis: delayed publish needs the Laravel-compatible layout")

ErrDelayedUnsupported is returned by delayed publishing and release in the default layout, which has no delayed set; use WithLaravelLayout.

View Source
var ErrInvalidRetryAfter = errors.New("babelqueue/redis: retry_after must not be negative")

ErrInvalidRetryAfter reports a negative retry_after (a configuration error, §1.7).

Functions

This section is empty.

Types

type Keys added in v1.2.0

type Keys struct {
	Ready    string // list
	Delayed  string // sorted set, scored by available-at
	Reserved string // sorted set, scored by the retry-after deadline
	Notify   string // list, blocking-pop wake-up
}

Keys are the Redis key names of one logical queue in the Laravel-compatible layout (broker-bindings.md §1.1).

func LaravelKeys added in v1.2.0

func LaravelKeys(prefix, queue string) Keys

LaravelKeys returns the §1.1 key names for queue under prefix.

type Option

type Option func(*Transport)

Option customizes New / NewWithClient.

func WithLaravelLayout added in v1.2.0

func WithLaravelLayout(prefix string) Option

WithLaravelLayout switches the transport to the Laravel-compatible key layout of broker-bindings.md §1.1 under prefix (empty means DefaultKeyPrefix). The default layout (a `<queue>` list plus a `<queue>:processing` list) is unchanged when this option is not used.

func WithMigrationBatchSize added in v1.3.0

func WithMigrationBatchSize(n int) Option

WithMigrationBatchSize bounds how many members one migrate (Laravel layout) or sweep (default layout) call moves; -1 (the default, DefaultMigrationBatchSize) moves them all. Zero or a value below -1 is ignored.

func WithProcessingSuffix

func WithProcessingSuffix(suffix string) Option

WithProcessingSuffix overrides the per-queue processing-list suffix (default ":processing").

func WithRetryAfter added in v1.2.0

func WithRetryAfter(d time.Duration) Option

WithRetryAfter sets the reservation window (default DefaultRetryAfter). In the Laravel layout an expired :reserved member is made ready again and counts as an attempt; in the default layout an expired deadline member is swept back to the ready list. A negative value is a configuration error (ErrInvalidRetryAfter); use WithoutRetryAfter to disable recovery.

func WithoutRetryAfter added in v1.3.0

func WithoutRetryAfter() Option

WithoutRetryAfter is the "retry_after: null" setting: this consumer never recovers expired :reserved members (Laravel layout) and its legacy deadline members score +inf, so it never sweeps its own reservations (it still sweeps other consumers' expired members).

type Transport

type Transport struct {
	// contains filtered or unexported fields
}

Transport implements babelqueue.Transport over Redis.

func New

func New(url string, opts ...Option) (*Transport, error)

New connects to Redis from a redis:// or rediss:// URL.

func NewWithClient

func NewWithClient(client *goredis.Client, opts ...Option) *Transport

NewWithClient wraps an existing go-redis client (bring your own pool/TLS/auth). A configuration error from an option (a negative retry_after) is reported by every operation.

func (*Transport) Ack

Ack acknowledges a reservation. Laravel layout: ZREM of the :reserved member. Default layout: the gated ack — remove the reservation's own deadline member and, only if that removal succeeded, exactly one copy of the processing item; an expired reservation is a no-op that logs a warning, so a late ack never deletes another consumer's copy.

func (*Transport) Close

func (t *Transport) Close() error

Close releases the underlying client.

func (*Transport) DeadLetter added in v1.3.0

func (t *Transport) DeadLetter(ctx context.Context, msg *babelqueue.ReceivedMessage, dlqQueue, body string) error

DeadLetter writes body to dlqQueue and drops the reservation in one gated step (babelqueue.DeadLetterer, §1.7). Default layout: behind the deadline-member gate shared with Ack and Release. Laravel layout: one script that ZREMs the reserved member and, only when that removed it, pushes body (and a notify token) to the dead-letter queue. A consumer whose reservation expired writes nothing and logs a warning, so the message is never both dead-lettered and handled by its new owner.

func (*Transport) Keys added in v1.2.0

func (t *Transport) Keys(queue string) Keys

Keys returns the key names the transport uses for queue. In the Laravel layout (WithLaravelLayout) these are the §1.1 names; in the default layout Ready is the queue list itself, Reserved is its processing list, and Delayed and Notify are empty (unused).

func (*Transport) Pop

func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration) (*babelqueue.ReceivedMessage, error)

Pop reserves the next message of queue, waiting up to timeout. timeout <= 0 means "wait until a message arrives or ctx ends"; a positive timeout returns (nil, nil) when nothing arrived in time.

Laravel layout: every iteration migrates due :delayed / expired :reserved members, runs the pop script and, when empty, waits at most one second (BLPOP on :notify, or a sleep for the last sub-second stretch because BLPOP cannot wait less than a second). Default layout: every iteration sweeps expired reservations and runs the non-blocking reserve script; between empty polls it sleeps 100 ms, doubling up to 1 s, reset by a message, never beyond the timeout.

func (*Transport) Publish

func (t *Transport) Publish(ctx context.Context, queue, body string) error

Publish appends body to queue (RPUSH). The bare wire envelope is stored byte-for-byte (no frame), so a non-otel publisher and a cross-version consumer interoperate exactly as before.

func (*Transport) PublishDelayed added in v1.3.0

func (t *Transport) PublishDelayed(ctx context.Context, queue string, env babelqueue.Envelope, delay time.Duration) error

PublishDelayed schedules env on queue for delivery after delay (Laravel layout only): ZADD to `:delayed` with score floor(now)+ceil(delay). No notify token is written — the migrate that moves it writes one (§1.6).

func (*Transport) PublishWithHeaders added in v1.1.0

func (t *Transport) PublishWithHeaders(ctx context.Context, queue, body string, headers map[string]string) error

PublishWithHeaders appends body together with out-of-band transport headers (babelqueue.HeaderPublisher). In the default layout, when the header map is non-empty the value RPUSHed is a transport-owned frame ({"__bq_frame":1,"headers":…,"body":<raw envelope>}) that carries the headers beside the frozen envelope (GR-1) — e.g. a W3C traceparent for cross-hop span linkage (ADR-0028). Blank keys/values are dropped; if nothing survives (or the map is nil/empty) it degrades to a byte-identical bare Transport.Publish.

In the Laravel-compatible layout (A3) no frame is ever written: the headers are dropped and the bare envelope is published, because a framed element would break Laravel workers and the pop script's cjson decode. A traceparent is therefore not carried through that layout; trace_id stays in the envelope (GR-4).

func (*Transport) Release added in v1.3.0

func (t *Transport) Release(ctx context.Context, msg *babelqueue.ReceivedMessage, delay time.Duration) error

Release returns a reserved message to the queue to be redelivered after delay (babelqueue.Releaser).

Laravel layout (A2): one script removes the reservation from `:reserved` and adds the body to `:delayed` at floor(now)+ceil(delay); the body is the message re-encoded by the SDK codec with attempts = job.attempts+1, unknown keys and their types preserved (ADR-0031). A body that does not decode yields babelqueue.ErrReleaseUnsupported (the App falls back to re-publish + ack).

Default layout: the release is a gated move back to the ready list with attempts+1 (the deadline-member gate of §1.7: a consumer whose reservation expired releases nothing). That layout has no delayed set, so a positive delay is not honoured — the message is re-queued at once, exactly what the App's re-publish fallback did, but now behind the gate so an expired reservation can no longer produce a duplicate. A body that does not decode yields babelqueue.ErrReleaseUnsupported here too: releasing it verbatim would never advance its attempts (this layout has no delivery counter), so the App would redeliver it forever. The App does not normally reach this branch for such a body: Transport.Pop flags it babelqueue.ReceivedMessage.Poison, so the App dead-letters the raw body before any release (binding §1.1); the check stays as defense for a caller that releases a message directly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL