Skip to content

Idempotency

Same payment_id. The webhook fires again. Sidekiq retries because the worker died mid-flight.

For work that must happen at most once — charge a card, send the email, reserve inventory — you need a stable key from the payload and a shared “already handled” claim. The second caller should stop before work runs.

idempotent_by wires that up with Stores: claim a slot using set(..., nx: true), mark success with xx: true, and you do not register middleware yourself. To serialize concurrent workers without blocking repeat runs later, see Locks.

Important

The claim runs after inputs are resolved and wraps the whole retry_on loop, not each retry attempt. One successful run marks the slot completed; a raised error deletes the claim so retries can hit work again. See Retries for what retries do not re-run (inputs, most callbacks).

Basic usage

Pick a key from context (after coercion) or build one in a block:

class ChargeCustomer < CMDx::Task
  idempotent_by :payment_id

  required :payment_id,   coerce: :string
  required :amount_cents, coerce: :integer

  def work
    context.charge = Stripe::Charge.create(
      amount:          amount_cents,
      currency:        "usd",
      idempotency_key: payment_id
    )
  end
end

ChargeCustomer.execute(payment_id: "pay_123", amount_cents: 9_900) # success
ChargeCustomer.execute(payment_id: "pay_123", amount_cents: 9_900) # skipped: duplicate
Option Default Plain-English meaning
ttl: 300 How long the store entry lives (seconds). After it expires, a new run can claim again.
namespace: "cmdx:idem" First piece of the store key (see Store keys).
on_duplicate: :skip :skip → skipped result; :fail → failed result (both set metadata[:code] == :duplicate).
if: / unless: nil Turn idempotency on or off for this run (same Util.satisfied? rules as other features).

Key resolvers

Same idea as elsewhere in CMDx — pick the shape that reads best:

Form How the segment is resolved
Symbol (e.g. :payment_id) context[payment_id] after inputs run
String Used literally as the segment
Block / Proc instance_exec on the task — self is the task; optional |task| arg matches callbacks
Other #call call(task) — e.g. a small resolver object
idempotent_by { "#{user_id}:#{context.idempotency_key}" }

idempotent_by ->(task) { "#{task.context.user_id}:#{task.context.idempotency_key}" }

If the resolved segment is blank (nil or empty string), idempotency steps aside — work runs every time and nothing is written to the store.

Store keys

Keys are built with CMDx::StoreKey.compose(namespace, task.class.name, segment) — colon-separated, each segment coerced like CMDx::Stores.key! (empty segments raise ArgumentError).

Example: cmdx:idem:ChargeCustomer:pay_123

While work is running, the value is "processing". After a successful run it becomes "completed". Duplicates read that value for the message (state=processing or state=completed).

Interaction with retry_on

Think of one outer envelope around the whole attempt, retries included:

resolve_inputs! → rate_limit → circuit_breaker → lock_with → idempotent claim → retry loop { work } → verify_outputs! → completed

If work raises StandardError, the claim is deleted before the exception reaches retry_on. The claim is also released when work calls skip! or fail!, or when output verification fails — so a later invocation can try again. When the wrapped block finishes cleanly, the slot is completed and the next call with the same key never enters work.

Use a shared store in production (Stores::Redis on CMDx.configuration.store). The in-memory default only sees your current process. See Stores.

TTL and crashes

If a worker dies after claiming but before success or cleanup, the key sticks around until ttl: expires — then another caller may run work again. Set ttl: for your worst-case runtime (and deploy pauses), not just HTTP timeouts.

Inheritance

Subclasses pick up the parent’s idempotency config. idempotent_by with no arguments returns what you have now (including inherited settings). Pass a new key resolver to replace the old one; pass only options to merge (e.g. idempotent_by ttl: 600 keeps the same resolver). Same layering idea as retry_on, except the key resolver is singular, not a list. idempotent_by false on a subclass turns it off.

class ApplicationTask < CMDx::Task
  idempotent_by :request_id, ttl: 600
end

class ChargeCustomer < ApplicationTask
  idempotent_by :payment_id # resolver → payment_id, ttl stays 600
end

class PreviewCharge < ApplicationTask
  idempotent_by false # no store claim; work runs every time
end

Skip vs fail on duplicates

:skip is for “already done — treat as success” (idempotent no-op, HTTP 2xx). :fail is for “no, you may not run this again” (for example HTTP 409).

Both set metadata[:code] == :duplicate and metadata[:idempotency_key] to the full store key.

Signals

Duplicates call skip! or fail! inside the catch around work, so your resolver does not keep running — under execute you get a Result with skipped? or failed?; under execute! you get a fault.

Telemetry

When idempotency is active for a run (enabled, gates pass, segment non-blank), CMDx emits telemetry only if someone subscribed.

Event When Extra event.payload keys
:idempotency_claimed processing slot claimed (nx: true) idempotency_key
:idempotency_completed Slot marked completed after the wrapped block succeeds idempotency_key
:idempotency_duplicate Key already held (processing or completed) code: :duplicate, idempotency_key, state

:idempotency_claimed and :idempotency_completed bracket a successful run (claim → work, retries, and output verification → completed). Failed runs delete the claim and do not emit :idempotency_completed.

CMDx.configure do |config|
  config.telemetry.subscribe(:idempotency_duplicate, ->(event) {
    StatsD.increment("cmdx.idempotency.duplicate", tags: [
      "task:#{event.task}",
      "state:#{event.payload[:state]}"
    ])
  })
end

:idempotency_duplicate fires before skip! / fail!, matching the metadata on the final Result.