Skip to content

Idempotency

minisagas calls your execute at least once, not exactly once. A retry calls it again after a failure. A timeout abandons an attempt that may already have reached the other side. A dropped response looks identical to a request that never arrived, and neither the library nor your task can tell them apart.

So the guarantee has to come from the operation itself: calling it twice with the same key must have the same effect as calling it once. That is a property of the API you are talking to, not something a library can add on top - what minisagas gives you is everything you need to derive a key that stays stable across those repeat calls.

Deriving a stable key

Everything you need is already in scope. input is handed to every task, and the task's name is a literal you wrote at the definition site:

ts
.task({
  name: "charge",
  execute: async ({ input, context }) =>
    context.payments.charge(input.amount, { idempotencyKey: `${input.orderId}:charge` }),
  compensate: async ({ result, context }) => context.payments.refund(result.chargeId),
  options: { retry: { attempts: 3, delay: 200 } },
})

All three attempts send the same key, so the second and third are recognized by the payment provider and return the first one's result instead of charging again. Every payment API worth using works this way; so do SQS message deduplication ids, and most "create" endpoints that accept a client-supplied id.

The two halves of that key are doing different jobs. input.orderId separates this run from every other run of the same saga. charge separates this task from the other tasks in the run, which would otherwise collide on the same key and have the second one silently return the first one's result.

Derive the run half from your input, never generate it. crypto.randomUUID() inside execute produces a new key on every attempt, which is no key at all. A uuid generated once outside the saga and passed in through input is fine - what matters is that the same logical run always presents the same value, even if you re-run the saga tomorrow from a queue redelivery.

Mapped and parallel tasks

A mapped iteration gets value, index and array on top of the usual arguments, so the per-item half of the key is right there:

ts
.mapParallelTask({
  name: "payout",
  iterator: ({ input }) => input.vendors,
  key: ({ value }) => value.id,
  execute: async ({ input, value, context }) =>
    context.payments.send(value.amount, {
      idempotencyKey: `${input.batchId}:payout:${value.id}`,
    }),
})

Prefer a stable field like value.id over index, for the same reason you prefer it in key: an iterator that returns its items in a different order on a re-run shifts every index, and every key with it. Reach for index only when the items have nothing stable to name them by.

A parallel group needs nothing special. Its branches run concurrently but each one is a distinct task with a distinct name, so the same ${run}:${task} shape keeps them apart.

The effect that lands after the failure

Retries are the visible half of the problem. The other half is a task that throws after its side effect succeeded - the charge went through and the acknowledgement was lost on the way back.

That task is not compensated. The rollback log is written only after execute resolves, so a task that threw was never recorded as having run, and the compensate you wrote next to it never fires. The charge stands, and nothing in result mentions it.

An idempotency key does not fix this on its own, but it makes the fix possible: the effect now has a name you can reach from outside the task that created it. Put a guard task in front of the risky one and hang the cleanup off that:

ts
.task({
  name: "chargeGuard",
  execute: async () => ({}),
  compensate: async ({ input, context }) =>
    context.payments.voidByKey(`${input.orderId}:charge`),
})
.task({
  name: "charge",
  execute: async ({ input, context }) =>
    context.payments.charge(input.amount, { idempotencyKey: `${input.orderId}:charge` }),
})

The guard cannot fail, so it is always in the rollback log, so its compensation always runs. Rollback walks backwards, so it runs after the point where charge's own compensation would have. Whether charge failed before the effect landed or after it, the void is asked about the same key, and a void against a key that was never charged is a no-op.

Worth the extra task only where the ambiguous window actually costs you something. For a task whose effect is cheap to leave behind, the plain shape is fine.

Compensations need this too

A rollback is an outside call like any other, and the same rules apply to it. If you retry one by hand, the loop inside compensate is repeating a real refund, so give it a key the same way:

ts
compensate: async ({ input, result, context }) =>
  context.payments.refund(result.chargeId, {
    idempotencyKey: `${input.orderId}:refund`,
  }),

What minisagas does not do

It does not check that your tasks are idempotent, because it cannot: execute is an opaque async function, and whether calling it twice is safe is a fact about a system on the other side of the network.

It does not deduplicate for you either. Holding "task charge of order ord-1 already ran" across a process restart means persistence, and minisagas has none by design - a crash mid-saga loses the run entirely. Within a single run the question does not arise: each task runs once, and the repeat calls are the retries you asked for.

What that leaves is the arrangement above. The library supplies stable inputs and a deterministic task name; you build the key; the remote service enforces it. If you need the dedup table itself to survive a restart, you need durable execution (Temporal, Restate) rather than a key.

MIT Licensed