Prevent duplicate runs
Use idempotency for repeated requests and singletons for overlapping work.
Runlane has two run-creation keys. They solve different problems.
| Key | Question it answers | Result on conflict |
|---|---|---|
idempotencyKey | “Have I already accepted this logical request?” | Return the active or retained owner |
singletonKey | “Is work for this resource already active?” | Reject a different overlapping run |
Define keys from stable business identity
import { task } from '@runlane/core'
import * as z from 'zod'
const env = z.object({ CUSTOMER_API_URL: z.url() }).parse(process.env)
export const refreshCustomer = task({
id: 'customers.refresh',
schema: z.object({ customerId: z.string() }),
idempotencyKey: ({ customerId }) => `refresh.${customerId}`,
idempotencyKeyTTL: '24h',
singletonKey: ({ customerId }) => `customer.${customerId}`,
async run({ customerId }, context) {
const response = await fetch(
new URL(`/customers/${encodeURIComponent(customerId)}/refresh`, env.CUSTOMER_API_URL),
{
method: 'POST',
signal: context.signal,
},
)
if (!response.ok) throw new Error(`Customer API returned ${response.status}`)
},
})In this example:
- repeated refresh requests for one customer return the same idempotency owner for 24 hours after it finishes.
- different requests cannot overlap while the same customer singleton is active.
Keys are scoped by the Runlane environment and task. Use a stable domain id. Do not use the current time or a random value when you want deduplication.
Understand idempotency retention
trigger() returns an outcome:
createdmeans it stored a new run.returned_existingmeans the idempotency key already had an active or retained owner.
idempotencyKeyTTL controls terminal retention. It requires an idempotency key. If omitted, Runlane uses contractDefaults.idempotency.defaultTTL; see defaults for its current value. Set IdempotencyKeyTTLMode.Active from @runlane/core to keep ownership only while the run is active. An operator can reset a terminal owner sooner, and pruning removes its ownership.
The TTL does not expire an active owner. It controls when a terminal key may be reused.
Understand singleton ownership
A singleton protects only active work. Runlane releases it when the owning run becomes terminal. A conflicting trigger with another idempotency identity fails instead of silently returning unrelated work.
Use a singleton for resources that must not update concurrently. Use queue concurrency when several runs may overlap up to a fixed limit.
Override keys at a trigger boundary
runlane.trigger() and context.trigger() accept key overrides. Use them when the caller owns the request identity. Keep task-level resolvers for rules that apply to every caller.
Do not set different key rules in producers and workers. Register the same task definition everywhere.
Verify the policy
Test:
- the same idempotency key returns one run.
- a retained owner remains until the expected TTL or reset.
- a singleton conflict is visible.
- singleton ownership is released at terminal state.
- different keys can proceed independently.
- invalid resolver output creates no partial run.
Use limit concurrency for shared capacity and prune old runs for retention.