babelqueue

package module
v1.20.0 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 16 Imported by: 10

README

BabelQueue for Go

CI Go Reference Go Report Card Live interop BabelQueue conformance

Polyglot Queues, Simplified. Read and write the canonical BabelQueue message envelope from Go — so your Go services exchange messages with Laravel, Symfony, Python, .NET and Node over one strict JSON format, on the broker you already run.

This is the framework-agnostic Go core: the wire-envelope codec, contracts and dead-letter helpers — zero dependencies (standard library only). The full standard is documented at babelqueue.com.

Installation

go get github.com/babelqueue/babelqueue-go

Requires Go >=1.26 — the core and every module share this floor.

Usage

package main

import (
	"fmt"

	babelqueue "github.com/babelqueue/babelqueue-go"
)

func main() {
	// Produce — build the canonical envelope and publish the JSON to your broker.
	env, err := babelqueue.Make(
		"urn:babel:orders:created",
		map[string]any{"order_id": 1042},
		babelqueue.WithQueue("orders"),
	)
	if err != nil {
		panic(err)
	}
	body, _ := env.Encode() // []byte of compact UTF-8 JSON
	// redisClient.RPush(ctx, "queues:orders", body)
	//   /  ch.PublishWithContext(ctx, "", "orders", false, false, amqp.Publishing{Body: body})

	// Consume — decode a message produced by ANY BabelQueue SDK.
	in, err := babelqueue.Decode(body)
	if err != nil || !in.Accepts() {
		return // malformed or unsupported — dead-letter / drop
	}
	switch in.URN() {
	case "urn:babel:orders:created":
		fmt.Println(in.Data["order_id"], in.TraceID) // 1042, the cross-service trace id
	}
}

The envelope is identical to every other SDK's:

{
  "job": "urn:babel:orders:created",
  "trace_id": "…",
  "data": { "order_id": 1042 },
  "meta": { "id": "…", "queue": "orders", "lang": "go", "schema_version": 1, "created_at": 1749132727000 },
  "attempts": 0
}

Encode disables HTML escaping and emits compact JSON — the same canonical wire form as the PHP and Python cores (the envelope frame is byte-identical). Key order inside your data map follows encoding/json (sorted), where PHP/Python keep insertion order — JSON object key order is insignificant, so consumers read them identically. JSON numbers decode into Data as float64 (encoding/json's default for any).

Trace continuation

Make mints a fresh trace_id unless you pass one — propagate an inbound trace across a hop with WithTraceID:

next, _ := babelqueue.Make(
	"urn:babel:shipping:requested",
	map[string]any{"order_id": 1042},
	babelqueue.WithTraceID(in.TraceID),
)
Vendor metadata (WithMeta)

Attach your own keys to meta with WithMeta. Keep vendor data under a single ext block (meta.ext.<vendor>, ADR-0031); the entries sit next to the core meta keys and survive every retry, dead-letter and redrive hop:

env, err := babelqueue.Make(
	"urn:acme:orders:created",
	map[string]any{"order_id": 1042},
	babelqueue.WithQueue("orders"),
	babelqueue.WithMeta("ext", map[string]any{"acme": map[string]any{"tier": "gold"}}),
)
// err wraps ErrReservedMetaKey for id/queue/lang/schema_version/created_at,
// ErrForbiddenKey for ts/max_retries/attempts/source, ErrDuplicateMetaKey for a
// repeated key and ErrEmptyMetaKey for "".

Encode also fails (with the same sentinels) when a forbidden or core-colliding key reaches the envelope through carried-over unknown keys, so such a key is never put on the wire. On the consumer side, Decode stays lenient: a forbidden key (or a urn next to a non-blank job) is dropped and reported through Envelope.Warnings(), which the App consumer logs with slog.Warn. A job that is empty or only ASCII whitespace falls back to the urn alias — in json.Unmarshal into an Envelope as well as in Decode. Redrive logs the same warnings for the DLQ bodies it reads.

Dead-letter
dl := babelqueue.Annotate(env, "failed", "orders", 3, err) // additive dead_letter block
body, _ := dl.Encode()
// publish body to the "orders.dlq" queue

Annotate returns a copy — the original envelope is preserved unchanged inside the dead-lettered message, so any-language consumers can still read it.

Runtime — produce & consume

The core is just the codec. An optional, still-zero-dependency runtime (App) ties it to a broker through a small Transport interface, with URN routing, attempts-based retry → dead-letter, and unknown-URN strategies:

import babelqueue "github.com/babelqueue/babelqueue-go"

app := babelqueue.NewApp(transport, // a Transport (see below)
    babelqueue.WithDefaultQueue("orders"),
    babelqueue.WithMaxAttempts(3),
    babelqueue.WithDeadLetter(true),
)

app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
    // ... handle env.Data; return an error to retry / dead-letter
    return nil
})

app.Publish(ctx, "urn:babel:orders:created", map[string]any{"order_id": 1042})
app.Consume(ctx) // blocks; routes by URN until ctx is cancelled

InMemoryTransport (in the core) backs tests and local runs with zero deps. Broker drivers live in separate modules, so the core itself never pulls a dependency:

go get github.com/babelqueue/babelqueue-go/redis   # Redis  (go-redis)
go get github.com/babelqueue/babelqueue-go/amqp    # RabbitMQ (amqp091-go)
import (
    babelqueue "github.com/babelqueue/babelqueue-go"
    "github.com/babelqueue/babelqueue-go/redis"
)

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

The RabbitMQ transport (amqp.New("amqp://…")) publishes to a durable queue with persistent delivery and the contract AMQP properties (type=URN, correlation_id=trace_id, x-schema-version/x-source-lang/x-attempts), and consumes with basic.get + manual ack. Implement babelqueue.Transport yourself to back the runtime with any other broker.

Idempotent consumption (dedupe on meta.id)

BabelQueue is at-least-once, so handlers should dedupe on the envelope's meta.id (ADR-0022). The idempotency subpackage (in the core, zero-dep) makes that a one-line wrap: a message whose id was already processed is skipped instead of re-run.

import "github.com/babelqueue/babelqueue-go/idempotency"

store := idempotency.NewInMemoryStore() // process-local reference store
app.Handle("urn:babel:orders:created", idempotency.Wrap(store, func(ctx context.Context, env babelqueue.Envelope) error {
    // runs at most once per meta.id; a thrown error leaves the id unmarked so retry/DLQ still apply
    return nil
}))

InMemoryStore is for tests / single-process consumers. A fleet of consumers needs a shared, persistent Store — two backends ship as separate submodules (so the core stays zero-dependency; a driver is pulled in only if you import them):

go get github.com/babelqueue/babelqueue-go/idempotency-redis      # Redis    (go-redis)
go get github.com/babelqueue/babelqueue-go/idempotency-postgres   # Postgres (database/sql + pgx)
import (
    "github.com/babelqueue/babelqueue-go/idempotency"
    redisstore "github.com/babelqueue/babelqueue-go/idempotency-redis"
)

store, _ := redisstore.New("redis://localhost:6379/0", redisstore.WithTTL(24*time.Hour))
defer store.Close()
app.Handle("urn:babel:orders:created", idempotency.Wrap(store, handler))

Both persistent backends implement the same idempotency.Store interface as the in-memory reference, with an atomic claim so concurrent consumers serialize on meta.id — Redis via SET key val NX PX <ttl>, Postgres via INSERT … ON CONFLICT … RETURNING on the primary key (DDL via schema.sql / Store.Migrate). A duplicate delivery is detected as already-seen; an optional WithTTL expires ids so they may be re-processed once the window lapses. See each submodule's README for details.

Transactional outbox (atomic write + relayed publish)

A plain producer makes a dual write — commit the business row and publish to the broker — two independent systems that disagree on a crash. The outbox subpackage (in the core, zero-dep) removes it (ADR-0029): the message is persisted into the same database, in the same transaction, as the business data, so it commits or rolls back atomically with it; a separate relay publishes the durable rows afterwards. It is the producer-side counterpart to consumer-side idempotency above.

The caller owns the transaction boundary — Outbox.Write does not begin or commit anything; it just encodes the envelope (frozen codec, bytes unchanged) and hands it to a Store the caller has bound to their open transaction:

import "github.com/babelqueue/babelqueue-go/outbox"

ob := outbox.New(store) // store is YOUR DB-bound outbox.Store (tx-scoped)

// inside the transaction you already opened around the business write:
insertOrder(ctx, tx, order)                  // the business write
env, _ := babelqueue.Make("urn:babel:orders:created", data, babelqueue.WithQueue("orders"))
ob.Write(env)                                // same tx, via a tx-bound Store
tx.Commit()                                  // both, or neither

The outbox.Store interface is the only thing you implement — the core pulls in no DB driver (GR-7). It stores the Encoded envelope verbatim and the relay publishes those exact bytes, so trace_id is preserved end-to-end (GR-4) and every SDK stays byte-compatible (GR-5):

type Store interface {
    Save(encoded []byte, queue string) (id string, err error)
    FetchUnpublished(limit int) ([]outbox.Record, error) // SHOULD claim rows (FOR UPDATE SKIP LOCKED)
    MarkPublished(ids []string) error
    MarkFailed(id, reason string) error
}

A separate relay drains the committed rows through the same Transport seam, on a worker loop or a cron:

relay := outbox.NewRelay(transport, store, outbox.Options{}) // zero Options = sane defaults

relay.Flush(ctx)      // publish one batch; mark each row published AFTER publish returns
relay.Drain(ctx, 0)   // loop Flush until a pass makes no progress (safety ceiling)

A row is marked published only after the transport accepts it — a crash in between re-publishes it next pass (at-least-once; the consumer dedupes on meta.id). A publish error is caught, the row is marked failed (with a bounded linear backoff) and left pending, so one poison row never blocks the batch. outbox.InMemoryStore is a process-local reference for tests / single-process demos (no real transaction — use a DB-backed adapter in production).

GDPR field encryption (encrypt PII inside data)

A registry can declare which data fields carry personal data with the x-gdpr-sensitive schema keyword, and bqschema gdpr audits/masks them (ADR-0030) — but that is governance. The gdpr subpackage (in the core, zero-dep) is the runtime half: a producer encrypts each marked field before publish, a consumer decrypts it after decode, so PII never sits in cleartext on the broker.

The envelope stays frozen (GR-1): Protect rewrites only the values inside data — a sensitive field's value becomes a ciphertext string — it never adds, renames or retypes an envelope field, meta.schema_version stays 1, and trace_id is untouched (GR-4). data remains pure JSON (GR-3), so any SDK can carry the envelope even without the key (it just can't read the protected fields).

The crypto is a Cipher interface the caller provides (KMS / Vault / HSM / tokenisation) — keeping the core dependency-free (GR-7). A stdlib-only AESGCMCipher (AES-256-GCM, random nonce, base64) ships as the reference:

import (
    babelqueue "github.com/babelqueue/babelqueue-go"
    "github.com/babelqueue/babelqueue-go/gdpr"
    "github.com/babelqueue/babelqueue-go/schema"
)

cipher, _ := gdpr.NewAESGCMCipher(key32) // your 32-byte key — or bind a KMS-backed Cipher
provider, _ := schema.NewDirProvider("registry.json")

// Producer — encrypt the marked fields after building data, before publishing.
env, _ := babelqueue.Make("urn:babel:users:registered", data, babelqueue.WithQueue("users"))
if sch, ok, _ := provider.Schema(env.URN()); ok {
    _ = schema.Check(provider, env.URN(), env.Data) // validate CLEARTEXT first (see note)
    _ = gdpr.Protect(env.Data, sch, cipher)         // email, profile.full_name, addresses[].line → ciphertext
}
body, _ := env.Encode()                             // ciphertext rides inside data; frame unchanged

// Consumer — decrypt after Decode, before the handler reads data.
in, _ := babelqueue.Decode(body)
if sch, ok, _ := provider.Schema(in.URN()); ok {
    _ = gdpr.Unprotect(in.Data, sch, cipher)        // restores the original values byte-for-byte
}

Protect/Unprotect are standalone helpers — strictly opt-in, no behaviour change when unused. The sensitive paths come from the same per-URN schema the validation path already loads, including nested objects (profile.full_name) and array items (addresses[].line); an absent field is skipped, not an error.

Validate cleartext, not ciphertext. A schema that constrains a sensitive field (minLength, enum, type:"integer", …) would reject the ciphertext string. Run schema.Check before Protect on the producer and after Unprotect on the consumer — or register schema.Wrap so it runs after your Unprotect step. A wrong-key Unprotect returns gdpr.ErrDecrypt, so the message takes the retry / dead-letter path rather than being handled blind.

This is the reference implementation the other SDKs mirror: the same Cipher seam, the same encrypt-values-in-place contract, the same frozen envelope.

What this core is

It enforces the contract: the envelope shape, URN identity, trace propagation, schema-version gating and the dead-letter block — with zero dependencies. The optional App runtime and InMemoryTransport are zero-dep too; only the Redis and RabbitMQ transport modules pull a broker driver, and only if you import them.

Unknown-URN strategy constants (StrategyFail, StrategyDelete, StrategyRelease, StrategyDeadLetter) drive the runtime's unknown-URN handling.

A delivery the runtime can neither process nor safely drop — a version-gated message (a bq-schema-version the consumer does not support) or a poison body — is dead-lettered as its raw body when dead-lettering is on (or StrategyDeadLetter), and dropped with an ack under StrategyDelete. With dead-lettering off, a transport that offers the optional NativeSettler capability (the sqs and azureservicebus modules) is not acknowledged: the delivery is left to the broker's native mechanism (SQS: no DeleteMessage, visibility expiry then RedrivePolicy; Azure Service Bus: an explicit native DeadLetter), logged, never retried by the SDK. Every other transport acknowledges and drops a version-gated message as before, and retries a poison body up to WithMaxAttempts (ADR-0037).

Conformance

This core passes the shared cross-SDK conformance suite (vendored under testdata/conformance/) — the same fixtures every BabelQueue SDK must satisfy, so a Go producer and, say, a Laravel consumer agree byte-for-byte.

go test ./...

License

MIT © Muhammet Şafak

Documentation

Overview

Package babelqueue is the framework-agnostic Go core of BabelQueue — "Polyglot Queues, Simplified".

It implements the canonical wire envelope so a Go service interoperates byte-for-byte with the PHP/Laravel, Python, ... SDKs over any broker, with no language-specific serialization on the wire:

env, _ := babelqueue.Make("urn:babel:orders:created", map[string]any{"order_id": 1042})
body, _ := env.Encode() // publish body to Redis / RabbitMQ / ...

in, _ := babelqueue.Decode(body)
if in.Accepts() {
	switch in.URN() {
	case "urn:babel:orders:created":
		// handle in.Data ...
	}
}

The package depends only on the standard library.

Full spec: https://babelqueue.com

Index

Constants

View Source
const (
	StrategyFail       = "fail"        // surface an error; let the worker decide
	StrategyDelete     = "delete"      // drop the message
	StrategyRelease    = "release"     // requeue for another consumer
	StrategyDeadLetter = "dead_letter" // route to the dead-letter queue
)

Unknown-URN strategies: what a consumer does with a message whose URN has no registered handler. These mirror the constants in every other SDK core.

View Source
const HeaderReplayBypass = "bq-replay-bypass"

HeaderReplayBypass is the out-of-band transport header Redrive stamps (when its options set Bypass) on a replayed message, and that the App runtime surfaces to the handler as IsReplay. It lets a handler skip external side-effects that already ran, with no change to the frozen envelope (ADR-0027).

View Source
const SchemaVersion = 1

SchemaVersion is the wire envelope schema version this core implements. It is versioned independently of the module's release (package) version: the wire format only changes when this number does.

View Source
const SourceLang = "go"

SourceLang is stamped into meta.lang for envelopes produced by this core.

Variables

View Source
var (
	// ErrEmptyURN is returned by Make when the URN is blank — a polyglot message
	// must expose a stable, non-empty URN so consumers can identify it without
	// relying on any language-specific class name.
	ErrEmptyURN = errors.New("babelqueue: a polyglot message must expose a stable, non-empty URN")

	// ErrUnknownURN signals that no handler is mapped for a message URN.
	ErrUnknownURN = errors.New("babelqueue: no handler is mapped for the message URN")

	// ErrReleaseUnsupported is returned (or wrapped) by a [Releaser] that cannot
	// release this particular message in place — e.g. a client lacking the broker's
	// release call, or a message without a reservation handle. Only this error makes
	// the [App] fall back to re-publish + ack; any other Release error leaves the
	// message reserved so the broker redelivers it (it is never deleted).
	ErrReleaseUnsupported = errors.New("babelqueue: in-place release is unsupported for this message")
)

Sentinel errors returned by the core.

View Source
var (
	// ErrReservedMetaKey: a meta entry collides with a core meta key (id, queue,
	// lang, schema_version, created_at). The core never overwrites its own keys.
	ErrReservedMetaKey = errors.New("babelqueue: meta key collides with a core meta key")
	// ErrForbiddenKey: a forbidden non-canonical key (message-envelope.md §10) was
	// supplied — through [WithMeta] or through extras carried by the envelope.
	ErrForbiddenKey = errors.New("babelqueue: forbidden non-canonical envelope key (message-envelope.md §10)")
	// ErrNoIdentity: decode found neither a valid string "job" nor a valid string
	// "urn" alias (not blank under the ASCII set), so the message has no identity
	// (ADR-0032 §6).
	ErrNoIdentity = errors.New("babelqueue: envelope has no valid job or urn identity")
	// ErrDuplicateMetaKey: [WithMeta] was given the same key more than once. A
	// repeat is rejected, never merged and never last-write-wins.
	ErrDuplicateMetaKey = errors.New("babelqueue: meta key supplied more than once")
	// ErrEmptyMetaKey: [WithMeta] was given an empty key.
	ErrEmptyMetaKey = errors.New("babelqueue: meta key must not be empty")
)

Sentinel errors for the extension-key rules of ADR-0031. They are returned by Make (through WithMeta) and by Envelope.Encode / Envelope.MarshalJSON; test with errors.Is. The wrapped message names the offending key as a JSON pointer (e.g. "/meta/ts").

Functions

func BypassExternalEffects added in v1.5.0

func BypassExternalEffects(ctx context.Context, fn func() error) error

BypassExternalEffects runs fn unless the current message is a replay (see IsReplay), in which case fn is skipped and nil is returned. Wrap the external, non-idempotent side of a handler — sending an email, charging a card, calling a third party — so a replay re-runs the idempotent core but does not re-fire effects that already happened:

app.Handle("urn:babel:orders:created", func(ctx context.Context, env babelqueue.Envelope) error {
	saveOrder(env)                       // idempotent core — always runs
	return babelqueue.BypassExternalEffects(ctx, func() error {
		return sendConfirmationEmail(env) // external effect — skipped on replay
	})
})

func HeadersFromContext added in v1.6.0

func HeadersFromContext(ctx context.Context) map[string]string

HeadersFromContext returns the out-of-band transport headers that arrived with the message currently being handled, or nil when none were carried (or the transport does not surface headers). The returned map is read-only — do not mutate it. It is the consume-side counterpart of HeaderPublisher: a handler or an optional wrapper (e.g. the otel module's WrapHandler) reads per-message metadata that travels beside the frozen envelope, never in it (GR-1).

func IsReplay added in v1.5.0

func IsReplay(ctx context.Context) bool

IsReplay reports whether the message currently being handled was redriven with the replay-bypass marker set — i.e. this is a deliberate replay, and external side-effects that already happened should be skipped. It reads the marker the runtime put on the context from the HeaderReplayBypass transport header.

func TruncateDeclaredVersion added in v1.11.0

func TruncateDeclaredVersion(v string) string

TruncateDeclaredVersion caps a rejected bq-schema-version value at 64 characters (runes) followed by "...", so a huge header cannot inflate a log line, a route or a DLQ record. A value of at most 64 characters is returned unchanged. Transports apply it to the rejected value before reporting it or building the reserved route; the gate comparison itself always uses the full, untrimmed value.

Types

type App added in v0.2.0

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

App is the optional BabelQueue runtime: it produces and consumes polyglot messages over a Transport. Routing is by URN; the wire format is the canonical envelope (via the same core codec), so it interoperates with the PHP/Laravel, Python, ... SDKs. Retry uses the top-level attempts counter; failures past MaxAttempts go to a dead-letter queue when enabled.

The core codec (Make/[Encode]/Decode) has zero dependencies; the App adds no dependencies either (broker drivers live in separate modules). An App is safe for concurrent Publish calls; run Consume from a single goroutine per queue.

func NewApp added in v0.2.0

func NewApp(transport Transport, opts ...AppOption) *App

NewApp builds a runtime over the given transport.

func (*App) Consume added in v0.2.0

func (a *App) Consume(ctx context.Context, queue ...string) error

Consume processes messages from the default queue (or the optional override) until ctx is cancelled. It blocks; run it in its own goroutine. A single bad message never stops the loop — it is retried or dead-lettered.

func (*App) DefaultQueue added in v1.19.0

func (a *App) DefaultQueue() string

DefaultQueue returns the queue Publish and Consume use when none is given: the value set with WithDefaultQueue, else "default". A per-call WithQueue overrides it for one publish, which this getter cannot see.

func (*App) Drain added in v0.2.0

func (a *App) Drain(ctx context.Context, queue string, max int) (int, error)

Drain processes up to max messages from queue and returns the count, stopping early when the queue is empty. With max <= 0 it drains until empty. Useful for tests and one-shot workers.

func (*App) Handle added in v0.2.0

func (a *App) Handle(urn string, handler Handler)

Handle registers handler as the consumer for urn (the last registration wins).

func (*App) Publish added in v0.2.0

func (a *App) Publish(ctx context.Context, urn string, data map[string]any, opts ...Option) (string, error)

Publish builds the canonical envelope for (urn, data) and publishes it. It returns the message id (meta.id). Pass envelope options such as WithQueue or WithTraceID to override the target queue or continue a trace.

func (*App) PublishEnvelopeWithHeaders added in v1.19.0

func (a *App) PublishEnvelopeWithHeaders(
	ctx context.Context,
	urn string,
	data map[string]any,
	headers map[string]string,
	opts ...Option,
) (Envelope, error)

PublishEnvelopeWithHeaders is App.PublishWithHeaders that returns the envelope it built instead of only the message id, and returns it on a failed publish too, so a wrapper (such as the otel module's producer span) can describe the message that was attempted.

The envelope counts as built only once it has been handed to the transport:

  • if Make or Envelope.Encode fails (an empty URN, a bad meta entry, a payload that cannot be serialised) nothing reaches the transport, and the result is the zero Envelope and the error;
  • if the transport rejects the publish, the result is the envelope that was handed to it (meta.id, trace_id and meta.queue are filled in) and the transport's error.

On success it returns the envelope and a nil error. The envelope is the one the App built, so its queue is the per-call WithQueue value when given, else App.DefaultQueue.

func (*App) PublishWithHeaders added in v1.6.0

func (a *App) PublishWithHeaders(
	ctx context.Context,
	urn string,
	data map[string]any,
	headers map[string]string,
	opts ...Option,
) (string, error)

PublishWithHeaders builds the canonical envelope for (urn, data) and publishes it together with out-of-band transport headers, returning the message id (meta.id). The headers ride beside the frozen envelope on the transport's per-message metadata channel (e.g. a W3C traceparent for cross-hop span linkage, ADR-0028) — the envelope itself is never touched (GR-1).

It is the produce-side counterpart of the headers the runtime surfaces to a handler via HeadersFromContext. When the transport implements HeaderPublisher the headers are propagated; otherwise it transparently falls back to a plain publish (the headers are dropped, exactly as Redrive degrades — no error, no regression), so callers need not branch on transport capability. Passing nil or empty headers is equivalent to App.Publish.

func (*App) Run added in v0.2.0

func (a *App) Run(ctx context.Context) error

Run is an alias for Consume on the default queue.

type AppOption added in v0.2.0

type AppOption func(*App)

AppOption customizes NewApp.

func WithAckErrorHandler added in v1.9.0

func WithAckErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption

WithAckErrorHandler registers fn to observe a failed acknowledgement (e.g. an SQS DeleteMessage error). An ack failure is reported distinctly and never treated as a handler failure: a message whose handler succeeded is not retried, released or dead-lettered because its ack failed — the broker redelivers it once its reservation lapses, which consumer-side idempotency absorbs. Without a handler the error is ignored.

func WithDeadLetter added in v0.2.0

func WithDeadLetter(enabled bool) AppOption

WithDeadLetter enables routing exhausted/failed messages to a dead-letter queue.

func WithDeadLetterErrorHandler added in v1.11.0

func WithDeadLetterErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption

WithDeadLetterErrorHandler registers fn to observe a failed dead-letter write (the publish to the DLQ, or the record's encoding, failed). It fires on three paths: a version-gated delivery, an undecodable (poison) body, and a message whose retries are exhausted (or that hit StrategyDeadLetter for an unknown URN). Such a message is never acknowledged: the App logs the failure, reports it here and hands the message back to the broker (see Rewinder). The gate and poison paths never run a handler; an exhausted message is delivered again, so its handler runs again until the DLQ write succeeds. Without a handler the error is only logged.

func WithDeadLetterObserver added in v1.19.0

func WithDeadLetterObserver(fn DeadLetterObserver) AppOption

WithDeadLetterObserver registers fn as the DeadLetterObserver (ADR-0039). A nil fn unregisters it. The core stays free of any telemetry dependency: the optional otel module provides an observer that increments the babelqueue.dlq.messages counter.

func WithDeadLetterQueue added in v0.2.0

func WithDeadLetterQueue(queue string) AppOption

WithDeadLetterQueue overrides the dead-letter queue name (default: the source queue plus the dead-letter suffix).

func WithDeadLetterSuffix added in v0.2.0

func WithDeadLetterSuffix(suffix string) AppOption

WithDeadLetterSuffix sets the suffix appended to the source queue to derive the dead-letter queue name (default ".dlq"; on Amazon SQS the transport's DeadLetterNamer gives "<queue>-dlq", FIFO "<queue>-dlq.fifo", and places an explicit suffix before ".fifo" too).

func WithDefaultQueue added in v0.2.0

func WithDefaultQueue(queue string) AppOption

WithDefaultQueue sets the queue used by Publish/Consume when none is given (default "default").

func WithMaxAttempts added in v0.2.0

func WithMaxAttempts(n int) AppOption

WithMaxAttempts sets how many delivery attempts a message gets before it is dead-lettered or dropped (default 3).

func WithPollTimeout added in v0.2.0

func WithPollTimeout(d time.Duration) AppOption

WithPollTimeout sets how long Pop blocks waiting for a message each iteration (default 1s). Ignored by transports that do not block.

func WithReleaseErrorHandler added in v1.9.0

func WithReleaseErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption

WithReleaseErrorHandler registers fn to observe a failed in-place release (e.g. an SQS ChangeMessageVisibility error such as AccessDenied). A failed release is still treated as handled: the message is neither re-published nor acked, and the broker redelivers it once its reservation lapses — so the retry waits for the queue's visibility timeout instead of WithRetryBackoff. ErrReleaseUnsupported is not reported (it is a capability signal, answered by re-publish + ack). It also observes the re-publish that answers that signal (a retry or an unknown-URN release on a transport without in-place release): when the re-publish fails the message is not acknowledged but handed back to the broker (see Rewinder; without it the message stays reserved and is redelivered per the transport's reservation semantics, e.g. a Redis message stays in its processing list until recovered), and the failure is reported here. Without a handler a release error is ignored and a re-publish error is only logged.

func WithRetryBackoff added in v1.9.0

func WithRetryBackoff(d time.Duration) AppOption

WithRetryBackoff sets how long a failed message waits before it is redelivered (default 0 = immediately). It applies to transports implementing Releaser (e.g. SQS, where it becomes the ChangeMessageVisibility timeout); transports without that capability re-publish the retry immediately.

func WithRetryBackoffMax added in v1.17.0

func WithRetryBackoffMax(d time.Duration) AppOption

WithRetryBackoffMax sets retry_backoff_max: the cap on a computed retry delay (default 0 = unlimited).

func WithRetryBackoffMultiplier added in v1.17.0

func WithRetryBackoffMultiplier(m float64) AppOption

WithRetryBackoffMultiplier sets retry_backoff_multiplier: the growth factor between consecutive retries (default 1 = constant delay). Values below 1 are ignored.

func WithRetryJitter added in v1.17.0

func WithRetryJitter(j RetryJitter) AppOption

WithRetryJitter sets retry_jitter: JitterNone (default) or JitterFull. Any other value is ignored.

func WithRewindPause added in v1.11.0

func WithRewindPause(d time.Duration) AppOption

WithRewindPause sets how long the App waits after handing a message whose dead-letter write failed (version-gated, poison, or retries exhausted) back to the broker before polling again (default 1s), so a persistent dead-letter outage does not spin.

func WithUnknownURNReleaseDelay added in v1.9.0

func WithUnknownURNReleaseDelay(d time.Duration) AppOption

WithUnknownURNReleaseDelay sets the redelivery delay for the StrategyRelease unknown-URN strategy — the contract's unknown_urn_release_delay (default 0). Like WithRetryBackoff, it takes effect on transports implementing Releaser.

func WithUnknownURNStrategy added in v0.2.0

func WithUnknownURNStrategy(strategy string) AppOption

WithUnknownURNStrategy sets what happens to a message whose URN has no handler: one of StrategyFail, StrategyDelete, StrategyRelease, StrategyDeadLetter.

type DeadLetter

type DeadLetter struct {
	Reason        string  `json:"reason"`
	Error         *string `json:"error"`     // null when no cause was supplied
	Exception     *string `json:"exception"` // null when no cause was supplied
	FailedAt      int64   `json:"failed_at"` // Unix milliseconds, UTC
	OriginalQueue string  `json:"original_queue"`
	Attempts      int     `json:"attempts"`
	Lang          string  `json:"lang"`
}

DeadLetter is the additive block appended to an envelope when a message is dead-lettered. The original envelope is preserved unchanged alongside it, so a consumer in any language can still read the original job, data and trace_id.

func (*DeadLetter) UnmarshalJSON added in v1.14.0

func (d *DeadLetter) UnmarshalJSON(raw []byte) error

UnmarshalJSON decodes a dead_letter object by exact, case-sensitive member name; the last occurrence of a duplicated member wins.

type DeadLetterEvent added in v1.19.0

type DeadLetterEvent struct {
	// Queue is the source queue the message was consumed from (meta.queue of the message when
	// it has one, else the queue the delivery came from).
	Queue string
	// DeadLetterQueue is the BabelQueue DLQ the record was written to. It is empty for a native
	// dead-letter (the broker's own $DeadLetterQueue sub-entity), where no BabelQueue DLQ is used.
	DeadLetterQueue string
	// Reason is the dead_letter.reason the SDK wrote into the record ("failed", "unknown_urn",
	// "poison"). It is empty for a raw version-gated body (no dead_letter block) and for a native
	// dead-letter.
	Reason string
}

DeadLetterEvent describes one dead-letter the SDK itself carried out and confirmed, as reported to a DeadLetterObserver (ADR-0039 section 5.1).

type DeadLetterNamer added in v1.18.0

type DeadLetterNamer interface {
	DeadLetterQueueName(queue, suffix string) string
}

DeadLetterNamer is an optional Transport capability: the name of the BabelQueue dead-letter queue for a source queue, for a broker whose queue-name rules the runtime-wide "<queue><suffix>" shape cannot satisfy. The App asks it whenever WithDeadLetterQueue was not set, passing the suffix set with WithDeadLetterSuffix, or "" when none was set. Amazon SQS implements it (a queue name cannot contain a dot and a FIFO name must end in ".fifo"): the default is "<queue>-dlq", FIFO "<queue>-dlq.fifo", and an explicit suffix is placed before ".fifo" too ("orders.fifo" + "-x" -> "orders-x.fifo"), never after it (ADR-0037 decision 7). A transport without it keeps "<queue><suffix>" with the default ".dlq".

type DeadLetterObserver added in v1.19.0

type DeadLetterObserver func(ctx context.Context, event DeadLetterEvent)

DeadLetterObserver is called once per dead-letter the App itself carried out, after it is confirmed (ADR-0039 section 5.1):

  • a record written to the BabelQueue DLQ (an exhausted retry, an unknown URN under StrategyDeadLetter, a poison body, a raw version-gated body);
  • a native dead-letter the SDK initiated, when the transport reports it through NativeDeadLetterSettler (Azure Service Bus).

It is not called when the write or the native call fails, when the broker dead-letters on its own (SQS RedrivePolicy, Service Bus MaxDeliveryCount), for a native settle that is a no-op, or when dead-lettering is off and the message is dropped. A panic raised by the observer is recovered and logged; it never changes how the message is settled.

type DeadLetterer added in v1.17.0

type DeadLetterer interface {
	DeadLetter(ctx context.Context, msg *ReceivedMessage, dlqQueue, body string) error
}

DeadLetterer is an optional Transport capability: write body to dlqQueue and settle msg (the equivalent of an ack) in one gated step. A transport whose acknowledgement is only valid for the consumer that still owns the reservation (Redis: the deadline-member gate of broker-bindings §1.7) implements it so a consumer whose reservation expired writes nothing — the new owner of the message handles it — instead of producing a duplicate in the dead-letter queue. When it returns nil the message is settled and the App does not ack it again; any error leaves the message unsettled (handed back to the broker).

type Envelope

type Envelope struct {
	Job        string         `json:"job"`      // the message URN (never a class name)
	TraceID    string         `json:"trace_id"` // correlation id, preserved across hops
	Data       map[string]any `json:"data"`     // the message payload
	Meta       Meta           `json:"meta"`
	Attempts   int            `json:"attempts"`              // top-level transport retry counter
	DeadLetter *DeadLetter    `json:"dead_letter,omitempty"` // present only once dead-lettered
	// contains filtered or unexported fields
}

Envelope is the canonical BabelQueue wire message: a strict, language-neutral JSON shape ({job, trace_id, data, meta, attempts}) that every SDK produces and consumes identically. The field order below is significant — it matches the other cores so the envelope frame encodes identically across languages. (Key order inside the user-supplied Data map is JSON-insignificant; encoding/json emits it sorted, where PHP/Python preserve insertion order — same JSON.)

func Annotate

func Annotate(env Envelope, reason, originalQueue string, attempts int, cause error) Envelope

Annotate returns a copy of the envelope with a dead_letter block attached, recording why and where it failed; it does not mutate the original. A non-nil cause fills in the error message and its Go type; both serialize as JSON null when cause is nil.

func Decode

func Decode(raw []byte) (Envelope, error)

Decode parses a raw JSON body into an Envelope. It accepts "urn" as an inbound alias for "job" (resolving it into Job) when "job" is missing, empty or only ASCII whitespace; when both are present "job" wins and "urn" is dropped with a warning. Unknown top-level and meta keys are kept and re-emitted by Encode; forbidden keys (message-envelope.md §10) are dropped with a warning (see Envelope.Warnings). It does not validate the contents — use Accepts for consumer-side validation. A non-object data block (e.g. a JSON array) is rejected with an error.

Envelope keys are matched by exact, case-sensitive spelling, like the canonical schema and every other SDK core: a wrongly cased key ("JOB", "Data", "META", "URN") is an unknown key — preserved and re-emitted, never read as the canonical field — so {"JOB":"urn:x"} has no identity. A key that occurs more than once resolves to its last occurrence (data is replaced, never merged).

A "job" that is not a string (null, number, boolean, object, array) is ignored with a warning and the identity falls back to a string "urn"; with neither a valid "job" nor a valid "urn", Decode returns ErrNoIdentity (ADR-0032 §6) together with the envelope decoded so far. A JSON `null` document has no identity either. Every consume path (App, Redrive and the transports) uses this decode, so a body without identity is poison there; a caller of json.Unmarshal that tolerates such a body can check errors.Is(err, ErrNoIdentity) and keep the envelope returned beside it.

func Make

func Make(urn string, data map[string]any, opts ...Option) (Envelope, error)

Make builds the canonical envelope for a (urn, data) pair. It mints a fresh trace id unless WithTraceID is given, starts attempts at 0, and stamps meta with a unique id, the source language ("go"), the schema version and a millisecond timestamp. It returns ErrEmptyURN when urn is blank, and the WithMeta error when an extension entry is rejected.

func (Envelope) Accepts

func (e Envelope) Accepts() bool

Accepts reports whether a consumer should accept this envelope. It rejects a missing URN, an unsupported meta.schema_version, a blank trace_id, or missing data — the consumer-side counterpart to the producer JSON Schema. (It accepts the urn alias, unlike the stricter producer schema.)

func (Envelope) Encode

func (e Envelope) Encode() ([]byte, error)

Encode renders the envelope as compact UTF-8 JSON with HTML escaping disabled (unescaped slashes and unicode) — the canonical wire form shared by every SDK. The envelope frame is identical across languages; key order within the Data map follows encoding/json (sorted), which is semantically the same JSON. Unknown keys captured by Decode are written after the known fields of their object.

func (Envelope) MarshalJSON added in v1.9.0

func (e Envelope) MarshalJSON() ([]byte, error)

MarshalJSON renders the known fields in canonical order, then any unknown keys captured by Decode (sorted, verbatim). An envelope without unknown keys encodes byte-for-byte as the plain struct would.

It rejects, with ErrForbiddenKey or ErrReservedMetaKey, an envelope whose extras hold a forbidden key (message-envelope.md §10, including a leftover top-level "urn") or a core meta key (ADR-0031 (c)); nothing is written. Decode never leaves such a key behind, so a decoded envelope always re-encodes.

func (Envelope) URN

func (e Envelope) URN() string

URN returns the message URN — the canonical job, with the urn alias already resolved by Decode (or json.Unmarshal).

func (*Envelope) UnmarshalJSON added in v1.9.0

func (e *Envelope) UnmarshalJSON(raw []byte) error

UnmarshalJSON decodes the known fields and captures unknown top-level and meta keys for re-emission. Forbidden keys are recorded as warnings and dropped.

Envelope keys are matched by exact, case-sensitive spelling, as the canonical schema and every other SDK core do: "JOB", "Data" or "META" are unknown keys, not the canonical fields. A key that occurs more than once resolves to its last occurrence (objects are replaced, never merged), though every occurrence is still type-checked, except "job": a "job" that is not a string is ignored with a warning and the identity falls back to a string "urn" (ADR-0032 §6). If that leaves no identity at all, decode fails with ErrNoIdentity (returning the envelope decoded so far beside it).

The "urn" inbound alias is resolved here exactly as Decode resolves it, so json.Unmarshal and Decode produce the same Envelope: a "job" that is missing, empty or only ASCII whitespace takes the value of a string "urn"; next to a job that is not blank, "job" wins and "urn" is dropped with a "/urn" warning.

func (Envelope) Warnings added in v1.9.0

func (e Envelope) Warnings() []string

Warnings returns the non-fatal problems Decode (or json.Unmarshal) found, such as a forbidden non-canonical key (message-envelope.md §10) that was dropped. Each warning names the offending key as a JSON pointer (e.g. "/meta/max_retries"). It is empty for a clean envelope and for envelopes built with Make.

Warnings only reflect the body as the consumer received it: a transport that reconciles attempts by re-encoding the body on redelivery (SQS, Kafka, Pulsar, Artemis, Azure Service Bus) has already dropped any forbidden key there, so a redelivered message may carry no warning even though its first delivery did.

type Handler added in v0.2.0

type Handler func(ctx context.Context, env Envelope) error

Handler processes one decoded message. Returning an error triggers the retry / dead-letter path; returning nil acknowledges the message.

type HeaderPublisher added in v1.5.0

type HeaderPublisher interface {
	PublishWithHeaders(ctx context.Context, queue, body string, headers map[string]string) error
}

HeaderPublisher is an optional Transport capability: publish a body together with out-of-band transport headers (e.g. the replay-bypass marker), for brokers that carry per-message metadata. A transport that does not implement it simply does not propagate headers — callers fall back to plain Publish (ADR-0027).

type HeaderRepublisher added in v1.19.0

type HeaderRepublisher interface {
	PublishFromWithHeaders(ctx context.Context, queue, body string, from *ReceivedMessage, headers map[string]string) error
}

HeaderRepublisher is an optional Transport capability: re-publish a body on behalf of a message the transport itself reserved AND attach out-of-band transport headers, keeping everything a KeyedRepublisher / RoutedPublisher keeps (Kafka: the record key, contract §6.5; Pulsar: the routing and version-gate properties, §5.2/§5.7). It is HeaderPublisher.PublishWithHeaders that does not forget the delivery it stands in for. Redrive uses it, in preference to a bare PublishWithHeaders, to stamp the bq-replay-bypass marker (ADR-0027) without losing that metadata. from is the reserved message, non-nil. A transport that does not implement it falls back to HeaderPublisher, which carries the headers but not the per-delivery metadata. Purely additive: no existing interface changes.

type InMemoryTransport added in v0.2.0

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

func NewInMemoryTransport added in v0.2.0

func NewInMemoryTransport() *InMemoryTransport

NewInMemoryTransport returns an empty in-process transport.

func (*InMemoryTransport) Ack added in v0.2.0

Ack is a no-op: Pop already removed the message.

func (*InMemoryTransport) Pop added in v0.2.0

Pop removes and returns the head of queue, or (nil, nil) when it is empty.

func (*InMemoryTransport) Publish added in v0.2.0

func (t *InMemoryTransport) Publish(_ context.Context, queue, body string) error

Publish appends body to the in-memory queue.

func (*InMemoryTransport) PublishWithHeaders added in v1.5.0

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

PublishWithHeaders appends body plus out-of-band headers (HeaderPublisher).

func (*InMemoryTransport) Size added in v0.2.0

func (t *InMemoryTransport) Size(queue string) int

Size reports how many messages are pending on queue (test helper).

type KeyedRepublisher added in v1.12.0

type KeyedRepublisher interface {
	PublishFrom(ctx context.Context, queue, body string, from *ReceivedMessage) error
}

KeyedRepublisher is an optional Transport capability: re-publish a body on behalf of a message the transport itself reserved, carrying whatever native per-message metadata the broker attaches that the plain Transport.Publish cannot express — for Kafka, the record key (contract §6.5). The App uses it for every re-publish it makes from a received message (retry, unknown-URN release fallback, dead-letter writes). from is the received message the republish stands in for, never nil. An implementation that also routes on ReceivedMessage.RouteURN (see RoutedPublisher) must honour that itself. A transport that does not implement this falls back to RoutedPublisher / plain Publish, so the metadata is simply not carried.

type Meta

type Meta struct {
	ID            string `json:"id"`
	Queue         string `json:"queue"`
	Lang          string `json:"lang"`
	SchemaVersion int    `json:"schema_version"`
	CreatedAt     int64  `json:"created_at"` // Unix milliseconds, UTC
}

Meta is the immutable per-message metadata block.

func (*Meta) UnmarshalJSON added in v1.14.0

func (m *Meta) UnmarshalJSON(raw []byte) error

UnmarshalJSON decodes a meta object by exact, case-sensitive member name; the last occurrence of a duplicated member wins. Unknown members are ignored here — Decode is what carries them along on the envelope.

type NativeDeadLetterSettler added in v1.19.0

type NativeDeadLetterSettler interface {
	NativeSettler
	SettleNativeDeadLetter(ctx context.Context, msg *ReceivedMessage, reason, description string) (deadLettered bool, err error)
}

NativeDeadLetterSettler is an optional Transport capability next to NativeSettler (ADR-0039 section 5.1): a native settle that also reports whether it dead-lettered the message. SettleNative alone cannot tell the Azure Service Bus explicit DeadLetterMessage (counted as a dead-letter) from the SQS no-op, or from the Service Bus abandon fallback (the broker's own MaxDeliveryCount, not counted), so a transport that can tell implements this as well. deadLettered is true only when the SDK itself moved the message to the broker's dead-letter sub-entity and the call succeeded; it is false whenever err is non-nil. The settle is exactly NativeSettler.SettleNative's. The interface embeds NativeSettler: the variant is an addition to the native settle capability, never a replacement for it, so a transport must implement both.

When a transport has this method the App calls it instead of SettleNative and notifies the DeadLetterObserver only on deadLettered == true. A transport with only NativeSettler (SQS) is never reported to the observer. The NativeSettler interface itself is unchanged.

type NativeSettler added in v1.18.0

type NativeSettler interface {
	SettleNative(ctx context.Context, msg *ReceivedMessage, reason, description string) error
}

NativeSettler is an optional Transport capability: leave a delivery the App can neither process nor safely drop to the broker's own mechanism, without acknowledging or deleting it and without re-publishing or retrying it. The App uses it, in place of an acknowledgement, for a version-gated delivery (broker-bindings §5.7 rule 3) and for a poison body (error-handling §7) when dead-lettering is off and the unknown-URN strategy is neither StrategyDelete nor a writable StrategyDeadLetter (ADR-0037 decisions 1 and 2). SQS implements it as "do nothing": no DeleteMessage, so visibility expiry redelivers the message and the queue's RedrivePolicy (maxReceiveCount) moves it to the native DLQ. Azure Service Bus implements it with an explicit native DeadLetter(reason, description) into $DeadLetterQueue. reason and description say why (e.g. "unsupported schema version", "poison"); the body is never altered. A transport that does not implement it keeps the previous settle (ack and drop), so the behaviour is decided by what the transport offers, never by its name.

type Option

type Option func(*makeConfig)

Option customizes Make.

func WithMeta added in v1.15.0

func WithMeta(key string, value any) Option

WithMeta adds one extension entry to the message's meta object, next to the core meta keys (ADR-0031 (b)). Each call supplies one flat meta key; a vendor block is supplied as the whole value of "ext" (snake_case, under meta.ext.<vendor>, is the recommended shape). The value is JSON-encoded immediately.

The entry is rejected — Make returns the error — when the key is empty (ErrEmptyMetaKey), collides with a core meta key (ErrReservedMetaKey), is a forbidden key of message-envelope.md §10 (ErrForbiddenKey), was already given to an earlier WithMeta (ErrDuplicateMetaKey), or its value cannot be encoded as JSON. Key matching is exact and case-sensitive: "TS" is not "ts".

func WithQueue

func WithQueue(queue string) Option

WithQueue sets the logical queue name recorded in meta.queue (default "default").

func WithTraceID

func WithTraceID(traceID string) Option

WithTraceID reuses an existing trace id (trace continuation) instead of minting a fresh one. A blank value is ignored.

type ReceivedMessage added in v0.2.0

type ReceivedMessage struct {
	Body   string
	Queue  string
	Handle any
	// Headers are out-of-band transport headers a [HeaderPublisher] carried with the
	// message (e.g. the bq-replay-bypass marker). Nil for transports that don't surface
	// them; reads are nil-safe.
	Headers map[string]string
	// DeliveryCount is the broker's native delivery count for this reservation
	// (1 on the first delivery), or 0 when the transport does not track one. The
	// [App] treats DeliveryCount−1 as a floor for the envelope's attempts, so the
	// WithMaxAttempts bound and dead-lettering still hold when a message is released
	// in place and its body cannot carry the counter (e.g. it does not decode).
	DeliveryCount int
	// RouteURN is an optional routing override: when non-empty the [App] looks the
	// handler up by this URN instead of the decoded body's URN. A transport whose
	// broker carries the URN out of band (e.g. the Pulsar bq-job property) fills it so
	// routing follows the property while the body is left untouched — the handler,
	// retries and the dead-letter record all keep the producer's original body.
	// Empty means "route on the body URN" (the default for every other transport).
	RouteURN string
	// Poison is set by a transport that has classified this delivery as poison itself (the
	// Redis zset layout's reserve script, broker-bindings §1.1: a head that does not decode or
	// whose attempts is not a number comes back as {job, job}). The [App] then never runs a
	// handler or a retry for it: it writes Body, unchanged, to the dead-letter queue and
	// settles the reservation, under every configuration (WithDeadLetter and the
	// unknown-URN strategy do not apply). False for every other delivery.
	Poison bool
}

ReceivedMessage is a message reserved from a queue, plus a transport-internal handle the transport uses to acknowledge it.

type RedriveItem added in v1.4.0

type RedriveItem struct {
	MessageID string // meta.id ("" if absent or undecodable)
	TraceID   string
	URN       string
	Reason    string // dead_letter.reason
	From      string // the DLQ it was read from
	To        string // target queue (the plan, even on a dry run; "" when skipped/undecodable)
	Redriven  bool   // true only when actually re-published to To
	Bypassed  bool   // true when the bq-replay-bypass header was stamped on the redriven message
}

RedriveItem records what happened to one message during a Redrive run.

type RedriveOptions added in v1.4.0

type RedriveOptions struct {
	// ToQueue overrides where messages are re-published. When empty, each message goes back
	// to its own dead_letter.original_queue; set it to a sandbox queue to replay safely.
	ToQueue string
	// Max caps how many messages are pulled from the DLQ (0 = all currently available).
	Max int
	// DryRun inspects without redriving: every message is read, reported, and returned to the
	// DLQ unchanged — nothing is re-published to a source/sandbox queue.
	// Decode warnings are logged for every message read, including dry-run and unselected ones.
	DryRun bool
	// Select, when non-nil, picks which messages to redrive (e.g. by reason or URN).
	// Unselected messages are returned to the DLQ unchanged.
	Select func(Envelope) bool
	// Bypass stamps the bq-replay-bypass transport header on each redriven message, so a
	// handler can skip external side-effects that already ran (see [BypassExternalEffects]).
	// It takes effect only when the transport is a [HeaderRepublisher] or a [HeaderPublisher]; otherwise it is a no-op
	// and the per-item Bypassed flag stays false (ADR-0027).
	Bypass bool
	// Timeout is the per-pop wait passed to the transport (default 1s).
	Timeout time.Duration
}

RedriveOptions configures a Redrive run.

type RedriveResult added in v1.4.0

type RedriveResult struct {
	Redriven int
	Skipped  int
	Items    []RedriveItem
}

RedriveResult summarizes a Redrive run.

func Redrive added in v1.4.0

func Redrive(ctx context.Context, t Transport, dlq string, opts RedriveOptions) (RedriveResult, error)

Redrive moves dead-lettered messages off the dlq queue and re-publishes each to its source queue (its dead_letter.original_queue) or to opts.ToQueue, resetting it for reprocessing: the dead_letter block is removed and attempts reset to 0, while job, trace_id, data and meta are preserved verbatim. It is the operator-side counterpart to the runtime's dead-letter routing — the contract leaves redrive to tooling, and this is that tool (ADR-0026).

Messages are drained from the DLQ first and then processed, so restored messages (skipped, dry-run, or undecodable) are never re-encountered in the same run. A DLQ message is acknowledged only after its re-publish succeeds; an undecodable body is restored, not lost.

type Releaser added in v1.9.0

type Releaser interface {
	Release(ctx context.Context, msg *ReceivedMessage, delay time.Duration) error
}

Releaser is an optional Transport capability: return a reserved message to its queue in place, to be redelivered after delay, instead of re-publishing a copy and acknowledging the original. Brokers whose contract binding mandates an in-place release (e.g. SQS §3: ChangeMessageVisibility on the receipt handle, with the broker's receive count as the authoritative attempt counter) implement it. When Release returns ErrReleaseUnsupported the App falls back to re-publish + ack; any other error leaves the message reserved (not acked), so the broker redelivers it once its reservation lapses.

type RetryJitter added in v1.17.0

type RetryJitter string

RetryJitter selects how the computed retry delay is randomised (ADR-0034 §9).

const (
	// JitterNone applies the computed delay exactly (the default).
	JitterNone RetryJitter = "none"
	// JitterFull draws the delay uniformly from [0, d(n)].
	JitterFull RetryJitter = "full"
)

type Rewinder added in v1.11.0

type Rewinder interface {
	Rewind(ctx context.Context, msg *ReceivedMessage) error
}

Rewinder is an optional Transport capability: hand an unacknowledged reserved message back to the broker so it is redelivered, and make sure nothing the consumer acknowledges afterwards can move past it. The App uses it when a message could not be dead-lettered (a version-gated delivery, an undecodable body, or a message whose retries are exhausted) and must not be lost: for an offset-committing broker (Kafka, where a commit is cumulative) it rewinds the consumer to the message's offset; for a lock-based broker it releases or abandons the lock. A DLQ redrive also calls it when it aborts after a publish failure: msg is then already restored and acknowledged, and the call stands for the rest of the drained batch, which was popped but never processed. That batch-wide meaning holds for an offset-committing broker (Kafka); on a lock-based broker (Artemis, Azure Service Bus, Pulsar) the call only touches the message it is given (already acknowledged, so a no-op) and the rest of the batch returns when its lock or ack timeout lapses. An implementation must rewind to what is committed, never past it, so an acknowledged message is not redelivered. A transport whose unacknowledged messages already come back on their own (SQS visibility timeout) need not implement it. It must never delete or acknowledge the message, and must not alter its body or attempt counters.

type RoutedPublisher added in v1.10.0

type RoutedPublisher interface {
	PublishRouted(ctx context.Context, queue, body, routeURN string) error
}

RoutedPublisher is an optional Transport capability: re-publish a body while keeping the routing identity the broker's own routing property carried on the original delivery (see ReceivedMessage.RouteURN). The App uses it for its retry and unknown-URN release re-publishes when the received message was routed on a URN that differs from its body, so a routing-property mismatch does not flip to the body URN on the next attempt (contract §5.2/§5.7). routeURN is non-empty; a transport that does not implement this falls back to plain Publish.

type Transport added in v0.2.0

type Transport interface {
	// Publish appends an already-encoded envelope to queue.
	Publish(ctx context.Context, queue, body string) error
	// Pop reserves the next message from queue, blocking up to timeout. It returns
	// (nil, nil) when no message arrives before the timeout.
	Pop(ctx context.Context, queue string, timeout time.Duration) (*ReceivedMessage, error)
	// Ack acknowledges (removes) a previously reserved message.
	Ack(ctx context.Context, msg *ReceivedMessage) error
}

Transport is the minimal broker contract the App runtime talks to: publish a raw (already-encoded) body, reserve the next message, and acknowledge it. The runtime is broker-agnostic — implement this to back it with any broker. The github.com/babelqueue/babelqueue-go/redis and .../amqp modules provide Redis and RabbitMQ implementations; InMemoryTransport backs tests and local runs.

Directories

Path Synopsis
amqp module
artemis module
asynq module
Package gdpr is the RUNTIME half of ADR-0030: SDK-side field-level encryption of the data fields a registry declared x-gdpr-sensitive.
Package gdpr is the RUNTIME half of ADR-0030: SDK-side field-level encryption of the data fields a registry declared x-gdpr-sensitive.
Package idempotency adds optional, dependency-free dedupe to a babelqueue consumer: it wraps a babelqueue.Handler so a message whose meta.id was already processed is skipped instead of run again (ADR-0022).
Package idempotency adds optional, dependency-free dedupe to a babelqueue consumer: it wraps a babelqueue.Handler so a message whose meta.id was already processed is skipped instead of run again (ADR-0022).
kafka module
machinery module
otel module
Package outbox adds an optional, dependency-free transactional outbox to a babelqueue producer (ADR-0029): the message is persisted into the same database, in the same transaction, as the business data — so it commits or rolls back atomically with it — and a separate Relay publishes the durable rows afterwards.
Package outbox adds an optional, dependency-free transactional outbox to a babelqueue producer (ADR-0029): the message is persisted into the same database, in the same transaction, as the business data — so it commits or rolls back atomically with it — and a separate Relay publishes the durable rows afterwards.
pulsar module
redis module
Package schema adds optional, dependency-free per-URN payload validation to a babelqueue producer or consumer (ADR-0024).
Package schema adds optional, dependency-free per-URN payload validation to a babelqueue producer or consumer (ADR-0024).
sqs module

Jump to

Keyboard shortcuts

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