Skip to content

Commit fd79852

Browse files
committed
wip: idempotency key
1 parent e2f6105 commit fd79852

12 files changed

Lines changed: 1295 additions & 30 deletions

File tree

ARCHITECTURE.md

Lines changed: 921 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ A TypeScript queue system inspired by Yii2-Queue architecture, providing a clean
77
- **Driver-based architecture**: Swap between DB, SQS, and File drivers seamlessly
88
- **Event-based jobs**: Register handlers for job types without complex classes
99
- **Type-safe API**: Full TypeScript support with driver-specific option validation
10-
- **Multiple backends**: Database, Amazon SQS, and File storage drivers
10+
- **Multiple backends**: Database, Amazon SQS, File, Redis, and In-Memory drivers
11+
- **Idempotency**: Built-in deduplication with idempotency keys
1112
- **Event system**: Hook into queue lifecycle events
13+
- **Plugin system**: Extend functionality with plugins (ECS task protection, metrics, etc.)
1214

1315
## Installation
1416

@@ -210,6 +212,62 @@ await sqsQueue.addJob('send-email', {
210212
});
211213
```
212214

215+
## Idempotency
216+
217+
Prevent duplicate job processing by using idempotency keys. When a job with the same `idempotencyKey` is added while an existing job with that key is still `waiting`, `delayed`, or `reserved`, the existing job ID is returned instead of creating a duplicate.
218+
219+
```typescript
220+
// First attempt - creates new job
221+
const id1 = await queue.addJob('send-email', {
222+
payload: { to: 'user@example.com', subject: 'Welcome', body: 'Hello!' },
223+
idempotencyKey: 'welcome-email-user123'
224+
});
225+
226+
// Second attempt with same key - returns existing job ID
227+
const id2 = await queue.addJob('send-email', {
228+
payload: { to: 'user@example.com', subject: 'Welcome', body: 'Hello!' },
229+
idempotencyKey: 'welcome-email-user123'
230+
});
231+
232+
console.log(id1 === id2); // true - no duplicate job created
233+
```
234+
235+
**Idempotency Key Lifecycle:**
236+
- Keys are automatically cleaned up when jobs complete (successfully or with failure)
237+
- After completion/failure, the same key can be reused for new jobs
238+
- Deduplication lasts as long as the job remains in the queue
239+
240+
**Driver Support:**
241+
242+
| Driver | Idempotency Support | Implementation |
243+
|--------|-------------------|----------------|
244+
| InMemoryQueue | ✅ Yes | In-memory Map |
245+
| FileQueue | ✅ Yes | JSON index file |
246+
| SQLiteQueue | ✅ Yes | Unique partial index |
247+
| RedisQueue | ✅ Yes | Redis hash |
248+
| MongooseQueue | ✅ Yes | MongoDB partial index |
249+
| SqsQueue | ✅ FIFO queues only | AWS MessageDeduplicationId |
250+
| DbQueue | ✅ Yes* | *Depends on adapter implementation |
251+
252+
**SQS FIFO Queue Example:**
253+
```typescript
254+
// SQS idempotency only works with FIFO queues (URLs ending in .fifo)
255+
const sqsQueue = new SqsQueue<MyJobs>(
256+
sqsClient,
257+
'https://sqs.us-east-1.amazonaws.com/123456789/my-queue.fifo' // Note: .fifo suffix
258+
);
259+
260+
await sqsQueue.addJob('send-email', {
261+
payload: { to: 'user@example.com', subject: 'Test', body: 'Hello' },
262+
idempotencyKey: 'unique-key-123' // Uses AWS MessageDeduplicationId
263+
});
264+
```
265+
266+
**Best Practices:**
267+
- Use descriptive keys that capture the uniqueness: `email-${userId}-${emailType}`, `invoice-${orderId}`
268+
- Include relevant identifiers in the key to prevent unintended deduplication
269+
- Don't reuse keys for semantically different operations
270+
213271
## Event Handling
214272

215273
```typescript

src/core/queue.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export abstract class Queue<
129129
delaySeconds: (options as any).delaySeconds ?? 0,
130130
priority: (options as any).priority ?? 0,
131131
pushedAt: new Date(),
132+
idempotencyKey: options.idempotencyKey,
132133
};
133134

134135
const event: QueueEvent = {

src/drivers/file.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface IndexData {
1717
waiting: Array<[id: string, ttr: number]>;
1818
delayed: Array<[id: string, ttr: number, time: number]>;
1919
reserved: Array<[id: string, ttr: number, attempt: number, time: number]>;
20+
idempotency?: Record<string, string>; // idempotency key -> job ID
2021
}
2122

2223
interface ReservedInfo {
@@ -63,15 +64,43 @@ export class FileQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, Fil
6364
}
6465

6566
protected async pushMessage(payload: unknown, meta: JobMeta): Promise<string> {
66-
const id = await this.touchIndex(async (data) => {
67+
const id = await this.touchIndex(async (data): Promise<string> => {
68+
// Initialize idempotency map if it doesn't exist
69+
if (!data.idempotency) {
70+
data.idempotency = {};
71+
}
72+
73+
// Check for idempotency
74+
if (meta.idempotencyKey) {
75+
const existingJobId = data.idempotency[meta.idempotencyKey];
76+
if (existingJobId) {
77+
// Check if job still exists in waiting, delayed, or reserved
78+
const stillExists =
79+
data.waiting.some(item => item[0] === existingJobId) ||
80+
data.delayed.some(item => item[0] === existingJobId) ||
81+
data.reserved.some(item => item[0] === existingJobId);
82+
83+
if (stillExists) {
84+
return existingJobId;
85+
}
86+
// Job completed/failed, clean up the idempotency mapping
87+
delete data.idempotency[meta.idempotencyKey];
88+
}
89+
}
90+
6791
const jobId = String(++data.lastId);
6892
const jobPath = path.join(this.path, `job${jobId}.data`);
69-
93+
7094
await fs.writeFile(jobPath, JSON.stringify({ payload, meta }), 'utf8');
7195
if (this.fileMode !== undefined) {
7296
await fs.chmod(jobPath, this.fileMode);
7397
}
7498

99+
// Store idempotency key mapping if provided
100+
if (meta.idempotencyKey) {
101+
data.idempotency[meta.idempotencyKey] = jobId;
102+
}
103+
75104
const ttr = meta.ttr ?? 300;
76105
const delay = meta.delaySeconds ?? 0;
77106

@@ -173,6 +202,16 @@ export class FileQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, Fil
173202
if (index !== -1) {
174203
data.reserved.splice(index, 1);
175204
}
205+
206+
// Clean up idempotency key mapping
207+
if (data.idempotency) {
208+
const idempotencyKey = Object.keys(data.idempotency).find(
209+
key => data.idempotency![key] === id
210+
);
211+
if (idempotencyKey) {
212+
delete data.idempotency[idempotencyKey];
213+
}
214+
}
176215
});
177216

178217
const jobPath = path.join(this.path, `job${id}.data`);
@@ -227,6 +266,7 @@ export class FileQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, Fil
227266
data.waiting = [];
228267
data.delayed = [];
229268
data.reserved = [];
269+
data.idempotency = {};
230270
});
231271

232272
const files = await fs.readdir(this.path);

src/drivers/memory.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
7878
private reservedJobs = new Set<string>();
7979
private delayedJobs = new Map<string, NodeJS.Timeout>(); // job ID -> timeout handle
8080
private ttrTimeouts = new Map<string, NodeJS.Timeout>(); // job ID -> TTR timeout handle
81+
private idempotencyKeys = new Map<string, string>(); // idempotency key -> job ID
8182
private nextJobId = 1;
8283
private maxJobs: number;
8384

@@ -87,9 +88,25 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
8788
}
8889

8990
protected async pushMessage(payload: unknown, meta: JobMeta): Promise<string> {
91+
// Check for idempotency
92+
if (meta.idempotencyKey) {
93+
const existingJobId = this.idempotencyKeys.get(meta.idempotencyKey);
94+
if (existingJobId) {
95+
const existingJob = this.jobs.get(existingJobId);
96+
// Only deduplicate if job is still waiting, delayed, or reserved
97+
if (existingJob && (existingJob.status === 'waiting' || existingJob.status === 'reserved')) {
98+
return existingJobId;
99+
}
100+
// If job is done or failed, remove the idempotency key mapping
101+
if (existingJob && (existingJob.status === 'done' || existingJob.status === 'failed')) {
102+
this.idempotencyKeys.delete(meta.idempotencyKey);
103+
}
104+
}
105+
}
106+
90107
const id = (this.nextJobId++).toString();
91108
const now = new Date();
92-
109+
93110
const job: InMemoryJobRecord = {
94111
id,
95112
payload,
@@ -100,24 +117,29 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
100117

101118
this.jobs.set(id, job);
102119

120+
// Store idempotency key mapping if provided
121+
if (meta.idempotencyKey) {
122+
this.idempotencyKeys.set(meta.idempotencyKey, id);
123+
}
124+
103125
// Handle delay
104126
if (meta.delaySeconds && meta.delaySeconds > 0) {
105127
job.delayTime = Date.now() + (meta.delaySeconds * 1000);
106128
job.status = 'waiting';
107-
129+
108130
// Schedule job to become available after delay
109131
const timeout = setTimeout(() => {
110132
this.delayedJobs.delete(id);
111133
this.addToWaitingQueue(id);
112134
}, meta.delaySeconds * 1000);
113-
135+
114136
this.delayedJobs.set(id, timeout);
115137
} else {
116138
// Add immediately to waiting queue
117139
this.addToWaitingQueue(id);
118140
}
119141
this.cleanupOldJobs();
120-
142+
121143
return id;
122144
}
123145

@@ -167,9 +189,14 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
167189

168190
job.status = 'done';
169191
job.doneAt = new Date();
170-
192+
171193
this.reservedJobs.delete(message.id);
172194
this.clearTtrTimeout(message.id);
195+
196+
// Clean up idempotency key mapping
197+
if (job.meta.idempotencyKey) {
198+
this.idempotencyKeys.delete(job.meta.idempotencyKey);
199+
}
173200
}
174201

175202
protected async failJob(message: QueueMessage, error: unknown): Promise<void> {
@@ -179,9 +206,14 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
179206
job.status = 'failed';
180207
job.doneAt = new Date();
181208
job.error = error instanceof Error ? error.message : String(error);
182-
209+
183210
this.reservedJobs.delete(message.id);
184211
this.clearTtrTimeout(message.id);
212+
213+
// Clean up idempotency key mapping to allow re-enqueueing after failure
214+
if (job.meta.idempotencyKey) {
215+
this.idempotencyKeys.delete(job.meta.idempotencyKey);
216+
}
185217
}
186218

187219
async status(id: string): Promise<JobStatus> {
@@ -259,6 +291,7 @@ export class InMemoryQueue<TJobMap = Record<string, any>> extends Queue<TJobMap,
259291
this.reservedJobs.clear();
260292
this.delayedJobs.clear();
261293
this.ttrTimeouts.clear();
294+
this.idempotencyKeys.clear();
262295
this.nextJobId = 1;
263296
}
264297

src/drivers/mongoose.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface IQueueJobDocument {
3737
status: "waiting" | "reserved" | "done" | "failed";
3838
attempt: number;
3939
errorMessage?: string;
40+
idempotencyKey?: string;
4041
}
4142

4243
// Mongoose document interface
@@ -65,6 +66,7 @@ export const QueueJobSchema = new Schema<IQueueJob>(
6566
},
6667
attempt: { type: Number, required: true, default: 0 },
6768
errorMessage: { type: String },
69+
idempotencyKey: { type: String, sparse: true },
6870
},
6971
{
7072
collection: "queue_jobs",
@@ -76,12 +78,32 @@ export const QueueJobSchema = new Schema<IQueueJob>(
7678
QueueJobSchema.index({ status: 1, delayTime: 1, priority: -1, pushTime: 1 });
7779
QueueJobSchema.index({ status: 1, expireTime: 1 });
7880
QueueJobSchema.index({ _id: 1, status: 1 });
81+
// Unique partial index for idempotency on active jobs
82+
QueueJobSchema.index(
83+
{ idempotencyKey: 1 },
84+
{ unique: true, sparse: true, partialFilterExpression: { status: { $in: ["waiting", "reserved"] } } }
85+
);
7986

8087
// Mongoose database adapter implementing DatabaseAdapter interface
8188
export class MongooseDatabaseAdapter implements DatabaseAdapter {
8289
constructor(private model: Model<IQueueJob>) {}
8390

8491
async insertJob(payload: unknown, meta: JobMeta): Promise<string> {
92+
// Check for idempotency
93+
if (meta.idempotencyKey) {
94+
const existingJob = await this.model
95+
.findOne({
96+
idempotencyKey: meta.idempotencyKey,
97+
status: { $in: ["waiting", "reserved"] },
98+
})
99+
.select("_id")
100+
.exec();
101+
102+
if (existingJob) {
103+
return existingJob._id.toHexString();
104+
}
105+
}
106+
85107
const now = new Date();
86108

87109
const doc = await this.model
@@ -97,6 +119,7 @@ export class MongooseDatabaseAdapter implements DatabaseAdapter {
97119
: null,
98120
status: "waiting",
99121
attempt: 0,
122+
idempotencyKey: meta.idempotencyKey,
100123
})
101124
.catch((err) => {
102125
if (err instanceof Error && "code" in err) {

0 commit comments

Comments
 (0)