Idempotency
How the enqueue dedup key is derived, prioritized, and overridden — and where the automation stops.
How the enqueue dedup key is derived, prioritized, and overridden — and where the automation stops.
A unique key on an enqueue coalesces a duplicate call onto the existing job instead of creating a second one — see Delivery guarantees for the underlying mechanism (an atomic, storage-level dedup index) and for designing task bodies that are safe to re-run. This page is about the key itself: how to derive it, which value wins when more than one is set, and where the automation stops.
Coalescing is one of three collapse rules; Flow control covers how it differs from a debounce window and from a rate limit.
This key collapses duplicate enqueues. It does nothing about a task body that already charged a card and then crashed before committing — for that you need a key the downstream service dedupes on, minted per checkpoint. See durable steps.
An explicit key works the same way in every binding — a second enqueue with the same key, while the first job is still pending or running, resolves to the existing job's id instead of creating a duplicate:
job1 = charge_customer.apply_async(args=(42, 1000), unique_key="charge:42:1000")
job2 = charge_customer.apply_async(args=(42, 1000), unique_key="charge:42:1000")
assert job2.id == job1.id # coalesced onto job1 — no duplicate createdconst first = queue.enqueue("chargeCustomer", [42, 1000], { uniqueKey: "charge:42:1000" });
const second = queue.enqueue("chargeCustomer", [42, 1000], { uniqueKey: "charge:42:1000" });
// second === first — coalesced, no duplicate createdEnqueueOptions once = EnqueueOptions.builder().uniqueKey("charge:42:1000").build();
String first = queue.enqueue(CHARGE_CUSTOMER, order, once);
String second = queue.enqueue(CHARGE_CUSTOMER, order, once); // same id — no new joblet first = queue.enqueue(charge_customer::call(42, 1_000).unique_key("charge:42:1000"))?;
let second = queue.enqueue(charge_customer::call(42, 1_000).unique_key("charge:42:1000"))?;
assert_eq!(first.id, second.id); // coalesced onto the first — no duplicate createdThe key frees up once that job leaves the pending/running state — completed, dead-lettered, or cancelled — so a later call with the same key creates a fresh job.
Where a binding has an idempotent flag, it derives this key for you instead of you building the string by hand: mark a task idempotent and FlexiQ hashes the task name and arguments into a dedup key on every enqueue.
@queue.task(idempotent=True)
def charge_customer(customer_id: int, amount_cents: int) -> str:
return payment_provider.charge(customer_id, amount_cents)
job1 = charge_customer.delay(42, 1000)
job2 = charge_customer.delay(42, 1000)
assert job1.id == job2.id # second call coalesced into the firstidempotent=True also works as a per-call override on apply_async() for a
task that wasn't registered idempotent by default.
public static final Task<Order> CHARGE_CUSTOMER =
Task.of("charge_customer", Order.class).idempotent(true);
String first = queue.enqueue(CHARGE_CUSTOMER, order);
String second = queue.enqueue(CHARGE_CUSTOMER, order); // same id — coalesced into the firstEnqueueOptions.Builder.idempotent(boolean) is the same toggle as a
per-call override on a task that isn't idempotent by default.
There's no idempotent flag on the task or the enqueue options today —
build a stable key yourself from whatever makes two calls "the same":
queue.task("chargeCustomer", async (customerId: number, amountCents: number) => {
await paymentProvider.charge(customerId, amountCents);
});
function enqueueCharge(customerId: number, amountCents: number) {
const key = `charge:${customerId}:${amountCents}`;
return queue.enqueue("chargeCustomer", [customerId, amountCents], { uniqueKey: key });
}
enqueueCharge(42, 1_999);
enqueueCharge(42, 1_999); // same key — coalesced into the first job.idempotent() on the call derives the key, and #[flexiq::task(idempotent)]
makes it the default for every call of that task:
#[flexiq::task(idempotent)]
fn charge_customer(customer_id: i64, amount_cents: i64) -> flexiq::Outcome<()> {
Ok(())
}
let first = queue.enqueue(charge_customer::call(42, 1_000))?;
let second = queue.enqueue(charge_customer::call(42, 1_000))?;
assert_eq!(first.id, second.id); // second call coalesced into the firstAn explicit .unique_key(..) on the same call beats .idempotent().
The auto-derived key hashes the task name and the serialized arguments — not the pre-serialization values — so two calls only collide when the serializer would emit identical bytes for both:
auto:<hex>
hex = sha256(utf8(task_name) + 0x00 + payload)[:32 hex chars]
The 0x00 separator is a single NUL byte, not a printable delimiter, so a
task name can never be mistaken for the start of a payload. The hash runs
over the pre-codec payload — the bytes before any payload codec — so a
nondeterministic codec (an AES-GCM nonce, for example) can't change the
dedup key from one call to the next.
Every binding that derives a key computes this exact same one for the same
(task_name, payload) pair, so idempotent enqueues from any of them dedupe
against each other when they share one queue.
There's no idempotent flag to auto-derive this key for you (see above),
but the recipe itself is public — build the same string yourself if you need
a Node producer's key to collide with a Python or Java producer's.
The crate pins the digest rather than merely asserting it is stable against
itself: a test asserts that one known call derives
auto:c3abb8ce58c62cb737c17b92beb816be, the exact string a producer in another
language computes for the same (task_name, payload) pair. Getting the NUL
separator or the 32-character truncation wrong still enqueues — it only stops
deduping across the boundary — so the expected value is written out where a
change to it fails the build.
Two jobs collide on a key only when they share a namespace. An unnamespaced job
is its own group, distinct from a job in a namespace called "". This makes a
key a per-tenant fact rather than a global one, so two tenants using the same
natural key — an order id, a customer id — do not suppress each other's work.
Before 2.0.0 the index was on the key alone, so a key reached across every namespace: the second tenant to use it got the first tenant's job back and never created its own. If you relied on that reach, key on something that is namespace-independent instead. A deployment with a single namespace, or none, is unaffected.
apply_async() and enqueue_many() accept three overlapping inputs; the
first one set wins:
| Parameter | Effect |
|---|---|
unique_key="…" | The literal storage key. Highest precedence — wins over everything below. |
idempotency_key="…" | Same effect as unique_key, kept as the more descriptive name for new code. |
idempotent=True / idempotent=False | Force auto-derivation on or off for this call, overriding the task's @queue.task(idempotent=True) default. |
Precedence, high to low: unique_key → idempotency_key → auto-derivation
(when idempotent is True, or the task default is, and no explicit key
was given).
EnqueueOptions accepts the same three overlapping inputs, resolved with
identical precedence before the enqueue call reaches native code —
idempotent/idempotencyKey never cross the JNI boundary themselves, only
the resolved uniqueKey does:
| Builder method | Effect |
|---|---|
.uniqueKey("…") (alias .jobId("…")) | The literal storage key. Highest precedence. |
.idempotencyKey("…") | Same effect as .uniqueKey, resolved locally before enqueue. |
.idempotent(true) / .idempotent(false) | Force auto-derivation on or off for this call, overriding Task.idempotent(true). |
Precedence, high to low: uniqueKey → idempotencyKey → auto-derivation
(when idempotent is true, or the task default is, and no explicit key
was given).
There's only one input — uniqueKey — so there's no precedence to resolve.
Omit the option to skip dedup for that call.
Two inputs, on EnqueueOptions and on TaskCall alike:
| Setter | Effect |
|---|---|
.unique_key("…") | The literal storage key. Highest precedence — wins over the line below. |
.idempotent() | Derive the key from the task name and payload, when no explicit key was set. |
There is no idempotency_key alias, and no way to turn derivation off for one
call — .idempotent() takes no argument and only ever sets the flag. A task
declared #[flexiq::task(idempotent)] therefore dedupes on every call unless
that call names its own .unique_key(..).
Batch enqueue trades the per-row dedup check for throughput, and the bindings don't all behave the same way when a batch contains a colliding key.
enqueueMany routes any job carrying a key through the same check-then-insert
path as a single enqueue — a duplicate inside the batch, or one that
collides with an already-active job, resolves to the existing job's id
instead of failing the batch:
List<String> ids = queue.enqueueMany(CHARGE_CUSTOMER, orders, once);
// duplicates map to the already-enqueued job's id, in input orderUnlike apply_async(), enqueue_many() inserts the whole batch in one
statement without a per-row existence check. A duplicate unique_key (or
auto-derived key) within the same call, or one that collides with an
already-active job, raises a RuntimeError and fails the whole batch
instead of coalescing.
enqueue_many() still accepts unique_keys, idempotency_keys, and a
uniform idempotent=True to resolve a key per row — just make sure the
resulting keys are distinct, or don't rely on dedup for that call:
queue.enqueue_many(
task_name="myapp.charge_customer",
args_list=[(42, 1000), (43, 2000)], # pre-deduplicated by the caller
idempotent=True,
)When collision resilience matters more than throughput, loop apply_async()
calls instead — each one goes through the coalescing check-then-insert path.
enqueueMany routes any entry carrying a uniqueKey through the same
check-then-insert path as a single enqueue — a duplicate inside the batch, or
one that collides with an already-active job, resolves to the existing job's id
instead of failing the batch:
const ids = queue.enqueueMany("chargeCustomer", [
{ args: [42, 1000], options: { uniqueKey: "charge:42" } },
{ args: [42, 1000], options: { uniqueKey: "charge:42" } },
]);
// ids[0] === ids[1] — one job, the duplicate maps to it, in input orderA batch carrying no uniqueKey at all keeps the plain bulk insert, so it costs
exactly what it did before; a single keyed entry switches the whole batch to the
check-then-insert path — still one storage call either way.
enqueue_batch switches the whole batch onto the unique check-then-insert path
as soon as one call in it carries a key, explicit or derived. A duplicate
inside the batch, or one colliding with an already-active job, resolves to the
existing job instead of failing the call:
let jobs = queue.enqueue_batch(vec![
charge_customer::call(42, 1_000).idempotent(),
charge_customer::call(42, 1_000).idempotent(),
])?;
// the duplicate resolves onto the already-active job rather than failingA batch where no call carries a key keeps the plain bulk insert — one transaction either way. A call carrying a debounce window is the one thing a batch refuses outright, because storage has no batched debounce to hand it to; enqueue that call on its own.
datetime.now(), uuid4(),
and similar produce a different hash on every call, so auto-derived dedup
never triggers. Move them inside the task body or pass a stable identifier
instead.charge(42, 1000) and
charge(amount=1000, customer_id=42) serialize differently and therefore
hash differently. Be consistent at the call site, or use an explicit
idempotency_key.idempotency_key for keys that must survive
a deploy.queue.test_mode() skips dedup. Tasks run synchronously without
storage in test mode, so idempotency is a no-op there..idempotencyKey(...) when that risk exists..idempotencyKey(...) for keys that must
survive a deploy.Whatever you build uniqueKey from, keep it stable for the equality you
want — a timestamp or a random id defeats dedup entirely, since every call
then gets its own key.
.idempotent()
never dedupes. Generate it inside the body, or pass a stable identifier..unique_key(..) for a key that has to survive a change to the
arguments themselves.enqueue matches on the window
before it looks at the dedup key, so a call carrying both never derives the
auto: key and never runs a dedup lookup at all.