Skip to content

Commit 45538b2

Browse files
committed
feat: implement unified Lua script for atomic job creation with idempotency support in Redis driver
1 parent fd79852 commit 45538b2

1 file changed

Lines changed: 85 additions & 42 deletions

File tree

src/drivers/redis.ts

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,33 @@ export interface RedisJobRequest<TPayload> extends BaseJobOptions, WithPriority,
1414
export interface RedisClient {
1515
// Counter operations
1616
incr(key: string): Promise<number>;
17-
17+
1818
// Hash operations
1919
hSet(key: string, field: string, value: string): Promise<number>;
2020
hGet(key: string, field: string): Promise<string | null>;
2121
hDel(key: string, fields: string[]): Promise<number>;
2222
hIncrBy(key: string, field: string, increment: number): Promise<number>;
23-
23+
2424
// Sorted set operations
2525
zAdd(key: string, members: { score: number; value: string }): Promise<number>;
2626
zRem(key: string, members: string[]): Promise<number>;
2727
zRangeByScore(
28-
key: string,
29-
min: number | string,
30-
max: number | string,
28+
key: string,
29+
min: number | string,
30+
max: number | string,
3131
options?: { REV?: boolean; LIMIT?: { offset: number; count: number } }
3232
): Promise<string[]>;
3333
zRange(
34-
key: string,
35-
start: number,
36-
stop: number,
34+
key: string,
35+
start: number,
36+
stop: number,
3737
options?: { REV?: boolean }
3838
): Promise<string[]>;
3939
zScore(key: string, member: string): Promise<number | null>;
40-
40+
41+
// Script operations
42+
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<any>;
43+
4144
// General operations
4245
del(keys: string[]): Promise<number>;
4346
}
@@ -72,46 +75,86 @@ export class RedisQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, Re
7275
}
7376

7477
protected async pushMessage(payload: unknown, meta: JobMeta): Promise<string> {
75-
// Check for idempotency
76-
if (meta.idempotencyKey) {
77-
const existingJobId = await this.redis.hGet(this.idempotencyKey, meta.idempotencyKey);
78-
if (existingJobId) {
79-
// Check if the job still exists
80-
const existingMessage = await this.redis.hGet(this.messagesKey, existingJobId);
81-
if (existingMessage) {
82-
// Job is still waiting, delayed, or reserved - return existing ID
83-
return existingJobId;
84-
}
85-
// Job completed/failed, clean up the idempotency mapping
86-
await this.redis.hDel(this.idempotencyKey, [meta.idempotencyKey]);
87-
}
88-
}
89-
90-
const id = (await this.redis.incr(this.idKey)).toString();
9178
const now = Math.floor(Date.now() / 1000);
9279

80+
// Unified Lua script for atomic job creation (with or without idempotency)
81+
const script = `
82+
local idempotencyKey = KEYS[1]
83+
local messagesKey = KEYS[2]
84+
local idKey = KEYS[3]
85+
local queueKey = KEYS[4]
86+
87+
local idempotencyField = ARGV[1]
88+
local message = ARGV[2]
89+
local queueScore = ARGV[3]
90+
91+
-- Check if idempotency key is provided (non-empty string)
92+
if idempotencyField ~= "" then
93+
-- Check if idempotency key exists
94+
local existingJobId = redis.call('HGET', idempotencyKey, idempotencyField)
95+
if existingJobId then
96+
-- Check if the job still exists
97+
local existingMessage = redis.call('HGET', messagesKey, existingJobId)
98+
if existingMessage then
99+
-- Job is still active, return existing ID
100+
return existingJobId
101+
else
102+
-- Job completed/failed, clean up the idempotency mapping
103+
redis.call('HDEL', idempotencyKey, idempotencyField)
104+
end
105+
end
106+
end
107+
108+
-- Create new job
109+
local newId = tostring(redis.call('INCR', idKey))
110+
111+
-- Store job message
112+
redis.call('HSET', messagesKey, newId, message)
113+
114+
-- Store idempotency mapping if key is provided
115+
if idempotencyField ~= "" then
116+
redis.call('HSET', idempotencyKey, idempotencyField, newId)
117+
end
118+
119+
-- Add to appropriate queue
120+
redis.call('ZADD', queueKey, queueScore, newId)
121+
122+
return newId
123+
`;
124+
93125
const message = JSON.stringify({ payload, meta });
94-
await this.redis.hSet(this.messagesKey, id, message);
126+
const priority = meta.priority || 0;
127+
const delaySeconds = meta.delaySeconds || 0;
95128

96-
// Store idempotency key mapping if provided
97-
if (meta.idempotencyKey) {
98-
await this.redis.hSet(this.idempotencyKey, meta.idempotencyKey, id);
99-
}
129+
let queueKey: string;
130+
let queueScore: string;
100131

101-
if (meta.delaySeconds && meta.delaySeconds > 0) {
102-
// Add to delayed set with execution time as score
103-
const executeAt = now + meta.delaySeconds;
104-
await this.redis.zAdd(this.delayedKey, { score: executeAt, value: id });
132+
if (delaySeconds > 0) {
133+
// Delayed job
134+
queueKey = this.delayedKey;
135+
queueScore = (now + delaySeconds).toString();
105136
} else {
106-
// Add to waiting queue with priority support
107-
// Use sorted set for priority, with higher priority = higher score
108-
// For same priority, use job ID for FIFO (lower ID = added earlier = higher priority within same priority)
109-
// Score = priority * 1000000000 + (1000000000 - jobId) for FIFO within same priority
110-
const priority = meta.priority || 0;
137+
// Waiting job with priority
138+
queueKey = this.waitingKey;
139+
// Note: we can't calculate the exact score without knowing the ID first
140+
// So we'll use a simpler approach: priority-based score only
141+
// The timePart will be handled separately after getting the ID
142+
queueScore = (priority * 1000000000).toString();
143+
}
144+
145+
const result = await this.redis.eval(script, {
146+
keys: [this.idempotencyKey, this.messagesKey, this.idKey, queueKey],
147+
arguments: [meta.idempotencyKey || '', message, queueScore]
148+
});
149+
150+
const id = result.toString();
151+
152+
// For non-delayed jobs, we need to update the score to include the FIFO timePart
153+
if (delaySeconds === 0) {
111154
const jobIdNum = parseInt(id);
112-
const timePart = 1000000000 - jobIdNum; // Invert job ID for FIFO within same priority
113-
const score = priority * 1000000000 + timePart;
114-
await this.redis.zAdd(this.waitingKey, { score, value: id });
155+
const timePart = 1000000000 - jobIdNum;
156+
const finalScore = priority * 1000000000 + timePart;
157+
await this.redis.zAdd(this.waitingKey, { score: finalScore, value: id });
115158
}
116159

117160
return id;

0 commit comments

Comments
 (0)