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
- Variables
- func BypassExternalEffects(ctx context.Context, fn func() error) error
- func HeadersFromContext(ctx context.Context) map[string]string
- func IsReplay(ctx context.Context) bool
- func TruncateDeclaredVersion(v string) string
- type App
- func (a *App) Consume(ctx context.Context, queue ...string) error
- func (a *App) DefaultQueue() string
- func (a *App) Drain(ctx context.Context, queue string, max int) (int, error)
- func (a *App) Handle(urn string, handler Handler)
- func (a *App) Publish(ctx context.Context, urn string, data map[string]any, opts ...Option) (string, error)
- func (a *App) PublishEnvelopeWithHeaders(ctx context.Context, urn string, data map[string]any, ...) (Envelope, error)
- func (a *App) PublishWithHeaders(ctx context.Context, urn string, data map[string]any, ...) (string, error)
- func (a *App) Run(ctx context.Context) error
- type AppOption
- func WithAckErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption
- func WithDeadLetter(enabled bool) AppOption
- func WithDeadLetterErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption
- func WithDeadLetterObserver(fn DeadLetterObserver) AppOption
- func WithDeadLetterQueue(queue string) AppOption
- func WithDeadLetterSuffix(suffix string) AppOption
- func WithDefaultQueue(queue string) AppOption
- func WithMaxAttempts(n int) AppOption
- func WithPollTimeout(d time.Duration) AppOption
- func WithReleaseErrorHandler(fn func(ctx context.Context, msg *ReceivedMessage, err error)) AppOption
- func WithRetryBackoff(d time.Duration) AppOption
- func WithRetryBackoffMax(d time.Duration) AppOption
- func WithRetryBackoffMultiplier(m float64) AppOption
- func WithRetryJitter(j RetryJitter) AppOption
- func WithRewindPause(d time.Duration) AppOption
- func WithUnknownURNReleaseDelay(d time.Duration) AppOption
- func WithUnknownURNStrategy(strategy string) AppOption
- type DeadLetter
- type DeadLetterEvent
- type DeadLetterNamer
- type DeadLetterObserver
- type DeadLetterer
- type Envelope
- type Handler
- type HeaderPublisher
- type HeaderRepublisher
- type InMemoryTransport
- func (t *InMemoryTransport) Ack(_ context.Context, _ *ReceivedMessage) error
- func (t *InMemoryTransport) Pop(_ context.Context, queue string, _ time.Duration) (*ReceivedMessage, error)
- func (t *InMemoryTransport) Publish(_ context.Context, queue, body string) error
- func (t *InMemoryTransport) PublishWithHeaders(_ context.Context, queue, body string, headers map[string]string) error
- func (t *InMemoryTransport) Size(queue string) int
- type KeyedRepublisher
- type Meta
- type NativeDeadLetterSettler
- type NativeSettler
- type Option
- type ReceivedMessage
- type RedriveItem
- type RedriveOptions
- type RedriveResult
- type Releaser
- type RetryJitter
- type Rewinder
- type RoutedPublisher
- type Transport
Constants ¶
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.
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).
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.
const SourceLang = "go"
SourceLang is stamped into meta.lang for envelopes produced by this core.
Variables ¶
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.
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
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
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
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
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 (*App) Consume ¶ added in v0.2.0
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
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
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
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.
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
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
WithDeadLetterQueue overrides the dead-letter queue name (default: the source queue plus the dead-letter suffix).
func WithDeadLetterSuffix ¶ added in v0.2.0
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
WithDefaultQueue sets the queue used by Publish/Consume when none is given (default "default").
func WithMaxAttempts ¶ added in v0.2.0
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
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
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
WithRetryBackoffMax sets retry_backoff_max: the cap on a computed retry delay (default 0 = unlimited).
func WithRetryBackoffMultiplier ¶ added in v1.17.0
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
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
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
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
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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 ¶
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
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
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
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
func (t *InMemoryTransport) Ack(_ context.Context, _ *ReceivedMessage) error
Ack is a no-op: Pop already removed the message.
func (*InMemoryTransport) Pop ¶ added in v0.2.0
func (t *InMemoryTransport) Pop(_ context.Context, queue string, _ time.Duration) (*ReceivedMessage, error)
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
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
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 WithTraceID ¶
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.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
amqp
module
|
|
|
artemis
module
|
|
|
asynq
module
|
|
|
azureservicebus
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). |
|
idempotency-postgres
module
|
|
|
idempotency-redis
module
|
|
|
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
|