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:

  1. Read meta.id. It is the per-message key. A message with no usable meta.id runs unchanged (fail-open) — the helper never drops work it cannot identify.
  2. If the id was already processed, skip and return. The runtime acks it, so the broker stops redelivering.
  3. 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).