effectmq
How-to guides

Retry, delay, and deduplicate work

Configure retry timing, delayed eligibility, and stable task identities.

This guide shows you how to control when work becomes eligible, which handler failures retry, and what happens when the same logical task is offered twice.

Give the task a stable identity

Derive the idempotency key from the application identity of the work:

import { Task, TaskQueue } from "@effectmq/core"
import { Effect, Schedule, Schema } from "effect"

const SendInvoice = Task.make({
  name: "send-invoice",
  schemaId: "send-invoice/v1",
  payload: { invoiceId: Schema.String, email: Schema.String },
  success: Schema.String,
  error: Schema.Struct({ retryable: Schema.Boolean, reason: Schema.String }),
  idempotencyKey: ({ invoiceId }) => invoiceId,
  maxRetries: 4,
  retry: {
    while: (error) => error.retryable,
    schedule: Schedule.exponential("1 second")
  }
})

const invoiceQueue = TaskQueue.make("invoices", SendInvoice)

const payload = { invoiceId: "inv-42", email: "ada@example.com" }

The retry schedule receives each typed handler failure. maxRetries is a separate upper bound; here no generation receives more than four handler retries even if the schedule continues.

Delay the first attempt

Pass a millisecond delay when offering the task:

const delayedOffer = TaskQueue.offer(
  invoiceQueue,
  payload,
  { delay: 30_000 }
)

The task is durable immediately but is not eligible for acquisition until the delay elapses. A running worker's maintenance loop promotes due work.

Handle duplicate offers

The default onDuplicate: "return-existing" leaves the stored generation unchanged:

const inspectDuplicate = Effect.gen(function* () {
  const first = yield* TaskQueue.offer(invoiceQueue, payload)
  const replay = yield* TaskQueue.offer(invoiceQueue, payload)

  if (replay._tag === "TaskExisting") {
    console.log(replay.handle.generation === first.handle.generation)
  }
})

Use onDuplicate: "new-generation" only when the existing generation has settled and the same logical identity must run again:

const nextGeneration = TaskQueue.offer(invoiceQueue, payload, {
  onDuplicate: "new-generation"
})

Do not select a new generation while recovering an IndeterminateWriteError. Retry the same payload and identity with the default duplicate policy so the retry either creates the missing generation or returns the committed one.

You are done when replays return TaskExisting, deliberate reruns return a higher generation, and retry timing matches the configured schedule. See task identity for the generation model.

On this page