Idempotency
At-least-once delivery is the floor every broker gives you: a message can arrive
more than once — after a worker crash before ack, a redelivery, or a manual replay. The
wire contract already carries the one field that makes
de-duplication possible across every language: meta.id, the unique identity of this
specific message (distinct from trace_id, which spans a whole causal chain — see
id vs trace_id).
This page is the cross-SDK contract for consuming meta.id and the optional,
dependency-free dedupe helper each SDK ships to enforce it (ADR-0022).
Status: Authoritative · Helper layer · envelope frozen at
schema_version: 1
This is a tooling layer, not a wire change. The helper adds nothing to the envelope
and removes nothing — meta.id is already in schema_version: 1. Idempotency is a
choice the consumer makes about how it treats that field.
The consumption contract
Handlers SHOULD be idempotent (error-handling §1): processing the same meta.id twice
MUST be safe. The helper gives you a mechanical way to honour that without rewriting each
handler — it is seen-set dedupe, keyed on meta.id:
- Read
meta.id. It is the per-message key. A message with no usablemeta.idruns unchanged (fail-open) — the helper never drops work it cannot identify. - If the id was already processed, skip and return. The runtime acks it, so the broker stops redelivering.
- Otherwise run the handler, then — on success only — remember the id. A handler that throws leaves the id unmarked, so retry / dead-letter still apply and a later delivery runs the handler again.
This is post-success dedupe under at-least-once, not exactly-once and not an in-flight concurrency lock. The store answers “was this id processed?” — never “what did it return”, because queue handlers have no response to replay (unlike an HTTP idempotency key). The narrow at-least-once window — the handler succeeds but the mark fails — is documented and bounded by the “handlers are idempotent” guarantee: a redelivery simply reprocesses harmlessly.
The Store
Every SDK exposes the same three-method record of processed ids, keyed on meta.id:
| Operation | Meaning |
|---|---|
seen(id) |
Has this id already been processed (remembered)? |
remember(id) |
Record this id as processed. |
forget(id) |
Drop an id (manual eviction; a backend may also expire on its own TTL). |
Each SDK ships a reference in-memory store implementing those three methods. It is process-local and not persistent — fine for tests and a single-process consumer, but not for a production fleet, where two workers each have their own map and neither sees the other’s “seen” set. Production needs a shared store (a Redis key, a database table, a PSR-16 cache) behind the same interface; the interface is stable, so swapping the backend is a one-line change.
For the full store contract — the in-memory reference, how to back it with a shared persistent store, the at-least-once → exactly-once-effect guarantee, and the broker-free conformance fixtures that lock it — see Idempotency Stores & the Effect Guarantee.
Go
The helper lives in the core module (stdlib only), so it ships with every consumer.
idempotency.Wrap(store, handler) decorates a handler; register it like any other:
import (
babelqueue "github.com/babelqueue/babelqueue-go"
"github.com/babelqueue/babelqueue-go/idempotency"
)
store := idempotency.NewInMemoryStore() // tests / single process
app.Handle("urn:babel:orders:created", idempotency.Wrap(store,
func(ctx context.Context, env babelqueue.Envelope) error {
// runs at most once per env.Meta.ID, even on redelivery
return chargeOnce(ctx, env)
}))
InMemoryStore satisfies the Store interface (Seen / Remember / Forget). For a
production fleet, implement those three methods over Redis or a database and pass your
store to Wrap unchanged.
PHP
BabelQueue\Idempotency\Idempotent::wrap($store, $handler) is the PHP mirror, composing
with the consume runtime’s ack-on-return / redeliver-on-throw contract:
use BabelQueue\Idempotency\Idempotent;
use BabelQueue\Idempotency\InMemoryStore;
$store = new InMemoryStore(); // tests / single process
$dispatch->on('urn:babel:orders:created', Idempotent::wrap($store,
function (\BabelQueue\Contracts\ConsumedMessage $message): void {
chargeOnce($message); // runs at most once per meta.id
}));
A production store implements BabelQueue\Idempotency\IdempotencyStore (seen /
remember / forget) over Redis, a database table, or any PSR-16 cache.
Python
babelqueue.idempotency.wrap(store, handler) keeps the handler’s signature (via
functools.wraps), so the runtime still passes it the right positional args:
from babelqueue import BabelQueue
from babelqueue.idempotency import InMemoryStore, wrap
app = BabelQueue("redis://localhost:6379/0", queue="orders")
store = InMemoryStore() # tests / single process
app.register("urn:babel:orders:created", wrap(store, on_order_created))
A production store implements the IdempotencyStore protocol (seen / remember /
forget) over a shared backend.
Per-SDK
The helper is optional and identical in spirit everywhere — same Store shape, same
seen-set semantics, same fail-open on a missing meta.id. The in-memory reference is for
tests and single-process consumers; bring a shared store for a fleet.
| SDK | Entry point | Reference store |
|---|---|---|
| Go | idempotency.Wrap(store, handler) |
idempotency.NewInMemoryStore() |
| PHP | Idempotent::wrap($store, $handler) |
BabelQueue\Idempotency\InMemoryStore |
| Python | idempotency.wrap(store, handler) |
babelqueue.idempotency.InMemoryStore |
| Node | Wrap(store, handler) |
InMemoryStore (@babelqueue/core) |
| Java | Idempotent.wrap(store, handler) |
in-memory Store (com.babelqueue.idempotency) |
See each SDK’s reference for store backends and registration: Go, PHP, Python, Node, Java.
For the related guard that stops an intended replay from re-firing external side-effects, see DLQ redrive & replay-bypass. For the producer-side mirror that makes the message commit atomically with your business write — removing the dual write — see the Transactional Outbox.
Continue to Observability (OpenTelemetry).