Idempotent Handlers
Why this matters
Section titled “Why this matters”Chronos guarantees at-least-once delivery. That means your handler may run more than once for the same job:
- A timeout occurs but your handler actually completed (late result report)
- A network partition prevents the result from reaching Chronos
- Push delivery: your endpoint processed the request but responded too slowly
If your handler charges a credit card, sends an email, or creates a record, running it twice without protection produces duplicates.
The keys available to you
Section titled “The keys available to you”| Field | Scope | Use when |
|---|---|---|
ctx.jobId | Same across retries | Side effects that must happen once per job (payments, emails, record creation) |
ctx.executionId | Unique per attempt | Per-attempt tracking (logging, metrics, attempt-specific records) |
Use jobId by default. It stays the same across retries, so if a retry fires after a side effect already succeeded, downstream systems detect the duplicate and skip it.
Use executionId only when each retry attempt should be treated as distinct (per-attempt logs, metrics, or audit records).
Patterns
Section titled “Patterns”Pass the job ID to APIs that support idempotency:
chronos.worker.handle<{ customerId: string; amount: number }>('charge', async (ctx) => { await stripe.charges.create({ amount: ctx.payload.amount, currency: 'usd', customer: ctx.payload.customerId, idempotency_key: ctx.jobId, });
return { charged: true };});Stripe (and many other payment APIs) will reject duplicate charges with the same idempotency key. Because jobId stays the same across retries, a timed-out attempt that actually succeeded won’t cause a second charge.
Use a unique constraint to prevent duplicate writes:
chronos.worker.handle<{ tenantId: string }>('sync-tenant', async (ctx) => { const [row, created] = await db.syncRuns.upsert({ where: { jobId: ctx.jobId }, create: { jobId: ctx.jobId, tenantId: ctx.payload.tenantId, status: 'running', }, update: {}, });
if (!created && row.status === 'completed') { return { synced: row.recordCount, deduplicated: true }; }
const count = await performSync(ctx.payload.tenantId);
await db.syncRuns.update({ where: { jobId: ctx.jobId }, data: { status: 'completed', recordCount: count }, });
return { synced: count };});Use Redis for lightweight dedup when you don’t need a persistent record:
chronos.worker.handle('send-notification', async (ctx) => { const key = `chronos:processed:${ctx.jobId}`; const alreadyProcessed = await redis.set(key, '1', 'NX', 'EX', 86400);
if (!alreadyProcessed) { return { skipped: true, reason: 'already_processed' }; }
await sendNotification(ctx.payload); return { sent: true };});NX ensures the key is only set if it doesn’t exist. If it already exists, another execution already processed this. Skip.
For state machine transitions, check the current state before acting:
chronos.worker.handle<{ trialId: string }>('expire-trial', async (ctx) => { const trial = await db.trials.findById(ctx.payload.trialId);
if (trial.status === 'expired') { return { alreadyExpired: true }; }
const updated = await db.trials.updateWhere( { id: ctx.payload.trialId, status: 'active' }, { status: 'expired', expiredAt: new Date() }, );
if (updated === 0) { return { raceCondition: true, currentStatus: trial.status }; }
return { expired: true };});The WHERE status = 'active' clause prevents double-expiration even under concurrent execution.
When you don’t need idempotency
Section titled “When you don’t need idempotency”Not every handler needs dedup. If the operation is naturally idempotent, you’re already safe:
- Read-only operations: fetching data, generating reports from current state
- Full-state overwrites:
UPDATE users SET last_seen = now() WHERE id = ? - Upserts:
INSERT ... ON CONFLICT DO UPDATEwith the same data - Deleting by ID: deleting something that’s already gone is a no-op
The rule: if running twice produces the same end state with no extra side effects, you’re fine without explicit dedup.