queue-tool 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +246 -0
- package/dist/adapters/mongodb.d.ts +14 -0
- package/dist/adapters/mongodb.js +173 -0
- package/dist/adapters/mongodb.test.d.ts +1 -0
- package/dist/adapters/mongodb.test.js +119 -0
- package/dist/adapters/postgres.d.ts +15 -0
- package/dist/adapters/postgres.js +219 -0
- package/dist/adapters/postgres.test.d.ts +1 -0
- package/dist/adapters/postgres.test.js +119 -0
- package/dist/adapters/redis.d.ts +13 -0
- package/dist/adapters/redis.js +243 -0
- package/dist/adapters/redis.test.d.ts +1 -0
- package/dist/adapters/redis.test.js +102 -0
- package/dist/factory.d.ts +4 -0
- package/dist/factory.js +35 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +37 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +2 -0
- package/dist/utils/cache.d.ts +40 -0
- package/dist/utils/cache.js +172 -0
- package/dist/utils/cache.test.d.ts +1 -0
- package/dist/utils/cache.test.js +35 -0
- package/dist/utils/logger.d.ts +15 -0
- package/dist/utils/logger.js +23 -0
- package/dist/utils/msgpack.d.ts +2 -0
- package/dist/utils/msgpack.js +13 -0
- package/dist/utils/msgpack.test.d.ts +1 -0
- package/dist/utils/msgpack.test.js +33 -0
- package/dist/worker.d.ts +23 -0
- package/dist/worker.js +100 -0
- package/dist/worker.test.d.ts +1 -0
- package/dist/worker.test.js +89 -0
- package/package.json +32 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostgresQueueAdapter = void 0;
|
|
4
|
+
const pg_1 = require("pg");
|
|
5
|
+
const msgpack_1 = require("../utils/msgpack");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const logger_1 = require("../utils/logger");
|
|
8
|
+
class PostgresQueueAdapter {
|
|
9
|
+
pool;
|
|
10
|
+
options;
|
|
11
|
+
pruneTimer;
|
|
12
|
+
constructor(connectionString, options) {
|
|
13
|
+
this.pool = new pg_1.Pool({ connectionString });
|
|
14
|
+
this.options = {
|
|
15
|
+
completedJobTtlMs: options?.completedJobTtlMs ?? 3600000, // 1 hour
|
|
16
|
+
failedJobTtlMs: options?.failedJobTtlMs ?? 86400000, // 24 hours
|
|
17
|
+
lockTimeoutMs: options?.lockTimeoutMs ?? 300000, // 5 minutes
|
|
18
|
+
pruneIntervalMs: options?.pruneIntervalMs ?? 60000, // 1 minute
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async init() {
|
|
22
|
+
try {
|
|
23
|
+
await this.pool.query('SELECT 1');
|
|
24
|
+
logger_1.logger.info('Postgres connection verified successfully');
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
logger_1.logger.error('Postgres connection health check failed', { error: e.message });
|
|
28
|
+
throw new Error(`Postgres connection health check failed: ${e.message}`);
|
|
29
|
+
}
|
|
30
|
+
const query = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
32
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
33
|
+
queue VARCHAR(255) NOT NULL,
|
|
34
|
+
payload BYTEA NOT NULL,
|
|
35
|
+
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
|
36
|
+
priority INT NOT NULL DEFAULT 0,
|
|
37
|
+
attempts INT NOT NULL DEFAULT 0,
|
|
38
|
+
max_attempts INT NOT NULL DEFAULT 3,
|
|
39
|
+
run_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
40
|
+
locked_at TIMESTAMP WITH TIME ZONE,
|
|
41
|
+
completed_at TIMESTAMP WITH TIME ZONE,
|
|
42
|
+
failed_at TIMESTAMP WITH TIME ZONE,
|
|
43
|
+
expires_at TIMESTAMP WITH TIME ZONE,
|
|
44
|
+
error TEXT,
|
|
45
|
+
worker_id VARCHAR(255)
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claim ON jobs (queue, status, run_at, priority DESC);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_expires ON jobs (expires_at) WHERE expires_at IS NOT NULL;
|
|
49
|
+
`;
|
|
50
|
+
await this.pool.query(query);
|
|
51
|
+
if (this.options.pruneIntervalMs && this.options.pruneIntervalMs > 0) {
|
|
52
|
+
this.pruneTimer = setInterval(() => {
|
|
53
|
+
this.pool.query(`DELETE FROM jobs WHERE expires_at <= NOW()`).catch(err => {
|
|
54
|
+
logger_1.logger.error('QueueTool Postgres background prune error', { error: err.message });
|
|
55
|
+
});
|
|
56
|
+
}, this.options.pruneIntervalMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async enqueue(queueName, payload, options) {
|
|
60
|
+
const id = options?.id ?? (0, crypto_1.randomUUID)();
|
|
61
|
+
const serializedPayload = (0, msgpack_1.serialize)(payload);
|
|
62
|
+
const priority = options?.priority ?? 0;
|
|
63
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
64
|
+
if (priority < -1000 || priority > 1000) {
|
|
65
|
+
logger_1.logger.error('Invalid job priority', { priority, queue: queueName });
|
|
66
|
+
throw new Error('Priority must be in the range [-1000, 1000]');
|
|
67
|
+
}
|
|
68
|
+
let runAt = options?.runAt ?? new Date();
|
|
69
|
+
if (options?.delayMs) {
|
|
70
|
+
runAt = new Date(Date.now() + options.delayMs);
|
|
71
|
+
}
|
|
72
|
+
// Check if job exists
|
|
73
|
+
const checkRes = await this.pool.query('SELECT 1 FROM jobs WHERE id = $1', [id]);
|
|
74
|
+
const existingJob = checkRes.rows.length > 0;
|
|
75
|
+
const query = `
|
|
76
|
+
INSERT INTO jobs (id, queue, payload, status, priority, attempts, max_attempts, run_at, locked_at, completed_at, failed_at, expires_at, error, worker_id)
|
|
77
|
+
VALUES ($1, $2, $3, 'pending', $4, 0, $5, $6, NULL, NULL, NULL, NULL, NULL, NULL)
|
|
78
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
79
|
+
queue = EXCLUDED.queue,
|
|
80
|
+
payload = EXCLUDED.payload,
|
|
81
|
+
status = 'pending',
|
|
82
|
+
priority = EXCLUDED.priority,
|
|
83
|
+
attempts = 0,
|
|
84
|
+
max_attempts = EXCLUDED.max_attempts,
|
|
85
|
+
run_at = EXCLUDED.run_at,
|
|
86
|
+
locked_at = NULL,
|
|
87
|
+
completed_at = NULL,
|
|
88
|
+
failed_at = NULL,
|
|
89
|
+
expires_at = NULL,
|
|
90
|
+
error = NULL,
|
|
91
|
+
worker_id = NULL
|
|
92
|
+
RETURNING *;
|
|
93
|
+
`;
|
|
94
|
+
const res = await this.pool.query(query, [
|
|
95
|
+
id,
|
|
96
|
+
queueName,
|
|
97
|
+
serializedPayload,
|
|
98
|
+
priority,
|
|
99
|
+
maxAttempts,
|
|
100
|
+
runAt
|
|
101
|
+
]);
|
|
102
|
+
// Send a notification for instant worker wakeup
|
|
103
|
+
await this.pool.query(`NOTIFY jobs_update, $1`, [queueName]);
|
|
104
|
+
logger_1.logger.info(existingJob ? 'Job updated/retried successfully' : 'Job enqueued successfully', { queue: queueName, jobId: id, priority });
|
|
105
|
+
return this.mapRowToJob(res.rows[0]);
|
|
106
|
+
}
|
|
107
|
+
async claimJob(queueName, workerId) {
|
|
108
|
+
const lockTimeoutMs = this.options.lockTimeoutMs || 300000;
|
|
109
|
+
// Atomically claim the next eligible job using SKIP LOCKED
|
|
110
|
+
const query = `
|
|
111
|
+
UPDATE jobs
|
|
112
|
+
SET status = 'processing', locked_at = NOW(), worker_id = $1, attempts = attempts + 1
|
|
113
|
+
WHERE id = (
|
|
114
|
+
SELECT id FROM jobs
|
|
115
|
+
WHERE queue = $2
|
|
116
|
+
AND (status = 'pending' OR (status = 'processing' AND locked_at <= NOW() - CAST($3 || ' milliseconds' AS INTERVAL)))
|
|
117
|
+
AND run_at <= NOW()
|
|
118
|
+
ORDER BY priority DESC, run_at ASC
|
|
119
|
+
LIMIT 1
|
|
120
|
+
FOR UPDATE SKIP LOCKED
|
|
121
|
+
)
|
|
122
|
+
RETURNING *;
|
|
123
|
+
`;
|
|
124
|
+
const res = await this.pool.query(query, [workerId, queueName, lockTimeoutMs]);
|
|
125
|
+
if (res.rows.length === 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const job = this.mapRowToJob(res.rows[0]);
|
|
129
|
+
logger_1.logger.info('Job claimed successfully', { queue: queueName, jobId: job.id, workerId });
|
|
130
|
+
return job;
|
|
131
|
+
}
|
|
132
|
+
async completeJob(jobId) {
|
|
133
|
+
const query = `
|
|
134
|
+
UPDATE jobs
|
|
135
|
+
SET status = 'completed', completed_at = NOW(), expires_at = NULL
|
|
136
|
+
WHERE id = $1
|
|
137
|
+
RETURNING queue;
|
|
138
|
+
`;
|
|
139
|
+
const res = await this.pool.query(query, [jobId]);
|
|
140
|
+
if (res.rows.length === 0) {
|
|
141
|
+
logger_1.logger.warn('Tried to complete job that does not exist', { jobId });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
logger_1.logger.info('Job completed successfully', { queue: res.rows[0].queue, jobId });
|
|
145
|
+
}
|
|
146
|
+
async failJob(jobId, error) {
|
|
147
|
+
// Check if we can retry the job
|
|
148
|
+
const checkQuery = `
|
|
149
|
+
SELECT queue, attempts, max_attempts FROM jobs WHERE id = $1;
|
|
150
|
+
`;
|
|
151
|
+
const checkRes = await this.pool.query(checkQuery, [jobId]);
|
|
152
|
+
if (checkRes.rows.length === 0) {
|
|
153
|
+
logger_1.logger.warn('Tried to fail job that does not exist', { jobId, error });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const { queue, attempts, max_attempts } = checkRes.rows[0];
|
|
157
|
+
if (attempts < max_attempts) {
|
|
158
|
+
// Retry: Set status back to pending, delay retry by 5 seconds
|
|
159
|
+
const retryQuery = `
|
|
160
|
+
UPDATE jobs
|
|
161
|
+
SET status = 'pending', run_at = NOW() + INTERVAL '5 seconds', error = $2, worker_id = NULL
|
|
162
|
+
WHERE id = $1;
|
|
163
|
+
`;
|
|
164
|
+
await this.pool.query(retryQuery, [jobId, error]);
|
|
165
|
+
logger_1.logger.warn('Job execution failed, scheduling retry', { queue, jobId, error, attempt: attempts, maxAttempts: max_attempts });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// Terminal Failure
|
|
169
|
+
const expiresAt = new Date(Date.now() + (this.options.failedJobTtlMs || 86400000));
|
|
170
|
+
const failQuery = `
|
|
171
|
+
UPDATE jobs
|
|
172
|
+
SET status = 'failed', failed_at = NOW(), error = $2, expires_at = $3
|
|
173
|
+
WHERE id = $1;
|
|
174
|
+
`;
|
|
175
|
+
await this.pool.query(failQuery, [jobId, error, expiresAt]);
|
|
176
|
+
logger_1.logger.error('Job failed terminally', { queue, jobId, error, attempts });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async listenToQueue(queueName, callback) {
|
|
180
|
+
const client = await this.pool.connect();
|
|
181
|
+
await client.query('LISTEN jobs_update');
|
|
182
|
+
const notificationListener = (msg) => {
|
|
183
|
+
if (msg.payload === queueName) {
|
|
184
|
+
callback();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
client.on('notification', notificationListener);
|
|
188
|
+
return async () => {
|
|
189
|
+
client.off('notification', notificationListener);
|
|
190
|
+
await client.query('UNLISTEN jobs_update');
|
|
191
|
+
client.release();
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
async close() {
|
|
195
|
+
if (this.pruneTimer) {
|
|
196
|
+
clearInterval(this.pruneTimer);
|
|
197
|
+
}
|
|
198
|
+
await this.pool.end();
|
|
199
|
+
}
|
|
200
|
+
mapRowToJob(row) {
|
|
201
|
+
return {
|
|
202
|
+
id: row.id,
|
|
203
|
+
queue: row.queue,
|
|
204
|
+
payload: (0, msgpack_1.deserialize)(row.payload),
|
|
205
|
+
status: row.status,
|
|
206
|
+
priority: row.priority,
|
|
207
|
+
attempts: row.attempts,
|
|
208
|
+
maxAttempts: row.max_attempts,
|
|
209
|
+
runAt: new Date(row.run_at),
|
|
210
|
+
lockedAt: row.locked_at ? new Date(row.locked_at) : undefined,
|
|
211
|
+
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
|
212
|
+
failedAt: row.failed_at ? new Date(row.failed_at) : undefined,
|
|
213
|
+
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
|
|
214
|
+
error: row.error || undefined,
|
|
215
|
+
workerId: row.worker_id || undefined,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
exports.PostgresQueueAdapter = PostgresQueueAdapter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const postgres_1 = require("./postgres");
|
|
4
|
+
const pg_1 = require("pg");
|
|
5
|
+
describe('PostgresQueueAdapter Integration Tests', () => {
|
|
6
|
+
let adapter;
|
|
7
|
+
let pool;
|
|
8
|
+
const queueName = 'test-postgres-integration-queue';
|
|
9
|
+
const connectionString = 'postgres://postgres:password@localhost:5432/queue_tool';
|
|
10
|
+
let isAvailable = true;
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
try {
|
|
13
|
+
pool = new pg_1.Pool({ connectionString });
|
|
14
|
+
// Quick probe
|
|
15
|
+
await pool.query('SELECT 1');
|
|
16
|
+
adapter = new postgres_1.PostgresQueueAdapter(connectionString);
|
|
17
|
+
await adapter.init();
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
isAvailable = false;
|
|
21
|
+
console.warn('Postgres is not running. Skipping Postgres integration tests.');
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
if (!isAvailable)
|
|
26
|
+
return;
|
|
27
|
+
await adapter.close();
|
|
28
|
+
await pool.end();
|
|
29
|
+
});
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
if (!isAvailable)
|
|
32
|
+
return;
|
|
33
|
+
// Clean test queue jobs
|
|
34
|
+
await pool.query('DELETE FROM jobs WHERE queue = $1', [queueName]);
|
|
35
|
+
});
|
|
36
|
+
test('should enqueue a job successfully', async () => {
|
|
37
|
+
if (!isAvailable)
|
|
38
|
+
return;
|
|
39
|
+
const payload = { task: 'test-postgres-enqueue', value: 100 };
|
|
40
|
+
const job = await adapter.enqueue(queueName, payload, { priority: 10 });
|
|
41
|
+
expect(job.id).toBeDefined();
|
|
42
|
+
expect(job.queue).toBe(queueName);
|
|
43
|
+
expect(job.payload).toEqual(payload);
|
|
44
|
+
expect(job.status).toBe('pending');
|
|
45
|
+
expect(job.priority).toBe(10);
|
|
46
|
+
});
|
|
47
|
+
test('should fail validation on invalid priority bounds', async () => {
|
|
48
|
+
if (!isAvailable)
|
|
49
|
+
return;
|
|
50
|
+
const payload = { task: 'test-priority-bounds' };
|
|
51
|
+
await expect(adapter.enqueue(queueName, payload, { priority: 2000 })).rejects.toThrow('Priority must be in the range [-1000, 1000]');
|
|
52
|
+
});
|
|
53
|
+
test('should support deduplication and idempotency with custom job ID', async () => {
|
|
54
|
+
if (!isAvailable)
|
|
55
|
+
return;
|
|
56
|
+
const payload = { task: 'dedup', step: 1 };
|
|
57
|
+
const customId = 'custom-postgres-job-id';
|
|
58
|
+
const job1 = await adapter.enqueue(queueName, payload, { id: customId, priority: 5 });
|
|
59
|
+
expect(job1.id).toBe(customId);
|
|
60
|
+
expect(job1.status).toBe('pending');
|
|
61
|
+
// Claim the job so it changes status to processing
|
|
62
|
+
const claimedJob = await adapter.claimJob(queueName, 'worker-1');
|
|
63
|
+
expect(claimedJob).not.toBeNull();
|
|
64
|
+
expect(claimedJob.id).toBe(customId);
|
|
65
|
+
expect(claimedJob.status).toBe('processing');
|
|
66
|
+
// Enqueue again with the same custom ID but updated payload
|
|
67
|
+
const updatedPayload = { task: 'dedup', step: 2 };
|
|
68
|
+
const job2 = await adapter.enqueue(queueName, updatedPayload, { id: customId, priority: 8 });
|
|
69
|
+
expect(job2.id).toBe(customId);
|
|
70
|
+
expect(job2.status).toBe('pending'); // Reset to pending
|
|
71
|
+
expect(job2.attempts).toBe(0); // Reset attempts
|
|
72
|
+
expect(job2.priority).toBe(8); // Updated priority
|
|
73
|
+
expect(job2.payload).toEqual(updatedPayload);
|
|
74
|
+
// Verify in db
|
|
75
|
+
const res = await pool.query('SELECT * FROM jobs WHERE id = $1', [customId]);
|
|
76
|
+
expect(res.rows.length).toBe(1);
|
|
77
|
+
expect(res.rows[0].status).toBe('pending');
|
|
78
|
+
expect(res.rows[0].attempts).toBe(0);
|
|
79
|
+
expect(res.rows[0].priority).toBe(8);
|
|
80
|
+
});
|
|
81
|
+
test('should claim and complete a job successfully', async () => {
|
|
82
|
+
if (!isAvailable)
|
|
83
|
+
return;
|
|
84
|
+
const payload = { task: 'test-postgres-claim', value: 200 };
|
|
85
|
+
const job = await adapter.enqueue(queueName, payload);
|
|
86
|
+
const claimedJob = await adapter.claimJob(queueName, 'worker-test-pg-1');
|
|
87
|
+
expect(claimedJob).not.toBeNull();
|
|
88
|
+
expect(claimedJob.id).toBe(job.id);
|
|
89
|
+
expect(claimedJob.status).toBe('processing');
|
|
90
|
+
expect(claimedJob.attempts).toBe(1);
|
|
91
|
+
await adapter.completeJob(claimedJob.id);
|
|
92
|
+
const res = await pool.query('SELECT status FROM jobs WHERE id = $1', [job.id]);
|
|
93
|
+
expect(res.rows.length).toBe(1);
|
|
94
|
+
expect(res.rows[0].status).toBe('completed');
|
|
95
|
+
});
|
|
96
|
+
test('should fail and retry job within maxAttempts limit', async () => {
|
|
97
|
+
if (!isAvailable)
|
|
98
|
+
return;
|
|
99
|
+
const payload = { task: 'test-postgres-retry' };
|
|
100
|
+
const job = await adapter.enqueue(queueName, payload, { maxAttempts: 2 });
|
|
101
|
+
const claimedJob = await adapter.claimJob(queueName, 'worker-test-pg-2');
|
|
102
|
+
expect(claimedJob).not.toBeNull();
|
|
103
|
+
await adapter.failJob(claimedJob.id, 'First test error pg');
|
|
104
|
+
const res1 = await pool.query('SELECT status, error FROM jobs WHERE id = $1', [job.id]);
|
|
105
|
+
expect(res1.rows[0].status).toBe('pending');
|
|
106
|
+
expect(res1.rows[0].error).toBe('First test error pg');
|
|
107
|
+
// Wait for the 5-second retry delay (or we can manually update run_at to past)
|
|
108
|
+
await pool.query('UPDATE jobs SET run_at = NOW() - INTERVAL \'1 minute\' WHERE id = $1', [job.id]);
|
|
109
|
+
// Claim again (2nd attempt)
|
|
110
|
+
const claimedJob2 = await adapter.claimJob(queueName, 'worker-test-pg-2');
|
|
111
|
+
expect(claimedJob2).not.toBeNull();
|
|
112
|
+
expect(claimedJob2.attempts).toBe(2);
|
|
113
|
+
// Fail again -> terminal failure
|
|
114
|
+
await adapter.failJob(claimedJob2.id, 'Second test error pg');
|
|
115
|
+
const res2 = await pool.query('SELECT status, error FROM jobs WHERE id = $1', [job.id]);
|
|
116
|
+
expect(res2.rows[0].status).toBe('failed');
|
|
117
|
+
expect(res2.rows[0].error).toBe('Second test error pg');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { IQueueAdapter, Job, EnqueueOptions, QueueAdapterOptions } from '../types';
|
|
2
|
+
export declare class RedisQueueAdapter implements IQueueAdapter {
|
|
3
|
+
private redis;
|
|
4
|
+
private options;
|
|
5
|
+
constructor(connectionString: string, options?: QueueAdapterOptions);
|
|
6
|
+
init(): Promise<void>;
|
|
7
|
+
enqueue<T>(queueName: string, payload: T, options?: EnqueueOptions): Promise<Job<T>>;
|
|
8
|
+
claimJob(queueName: string, workerId: string): Promise<Job | null>;
|
|
9
|
+
completeJob(jobId: string): Promise<void>;
|
|
10
|
+
failJob(jobId: string, error: string): Promise<void>;
|
|
11
|
+
getJob(jobId: string): Promise<Job | null>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RedisQueueAdapter = void 0;
|
|
7
|
+
const ioredis_1 = __importDefault(require("ioredis"));
|
|
8
|
+
const msgpack_1 = require("../utils/msgpack");
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
const logger_1 = require("../utils/logger");
|
|
11
|
+
class RedisQueueAdapter {
|
|
12
|
+
redis;
|
|
13
|
+
options;
|
|
14
|
+
constructor(connectionString, options) {
|
|
15
|
+
this.redis = new ioredis_1.default(connectionString);
|
|
16
|
+
this.options = {
|
|
17
|
+
completedJobTtlMs: options?.completedJobTtlMs ?? 3600000, // 1 hour
|
|
18
|
+
failedJobTtlMs: options?.failedJobTtlMs ?? 86400000, // 24 hours
|
|
19
|
+
lockTimeoutMs: options?.lockTimeoutMs ?? 300000, // 5 minutes
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async init() {
|
|
23
|
+
try {
|
|
24
|
+
await this.redis.ping();
|
|
25
|
+
logger_1.logger.info('Redis connection verified successfully');
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
logger_1.logger.error('Redis connection health check failed', { error: e.message });
|
|
29
|
+
throw new Error(`Redis connection health check failed: ${e.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async enqueue(queueName, payload, options) {
|
|
33
|
+
const priority = options?.priority ?? 0;
|
|
34
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
35
|
+
if (priority < -1000 || priority > 1000) {
|
|
36
|
+
logger_1.logger.error('Invalid job priority', { priority, queue: queueName });
|
|
37
|
+
throw new Error('Priority must be in the range [-1000, 1000]');
|
|
38
|
+
}
|
|
39
|
+
let id = options?.id;
|
|
40
|
+
let existingJob = null;
|
|
41
|
+
if (id) {
|
|
42
|
+
existingJob = await this.getJob(id);
|
|
43
|
+
}
|
|
44
|
+
if (!id) {
|
|
45
|
+
id = (0, crypto_1.randomUUID)();
|
|
46
|
+
}
|
|
47
|
+
const serializedPayload = (0, msgpack_1.serialize)(payload);
|
|
48
|
+
let runAt = options?.runAt ?? new Date();
|
|
49
|
+
if (options?.delayMs) {
|
|
50
|
+
runAt = new Date(Date.now() + options.delayMs);
|
|
51
|
+
}
|
|
52
|
+
const jobKey = `job:${id}`;
|
|
53
|
+
const runAtTime = runAt.getTime();
|
|
54
|
+
// Calculate score. A higher priority should run first.
|
|
55
|
+
// Base score is the timestamp in ms. We subtract priority * 10,000,000,000 to push prioritized jobs to the front.
|
|
56
|
+
const score = runAtTime - (priority * 10000000000);
|
|
57
|
+
// Pipeline the writes to reduce round-trips and improve enqueue performance.
|
|
58
|
+
const pipeline = this.redis.multi();
|
|
59
|
+
pipeline.hmset(jobKey, 'id', id, 'queue', queueName, 'payload', serializedPayload, 'status', 'pending', 'priority', priority.toString(), 'attempts', '0', 'max_attempts', maxAttempts.toString(), 'run_at', runAtTime.toString());
|
|
60
|
+
pipeline.hdel(jobKey, 'worker_id', 'locked_at', 'error', 'completed_at', 'failed_at');
|
|
61
|
+
pipeline.zrem(`queue:${queueName}:processing`, id);
|
|
62
|
+
pipeline.zadd(`queue:${queueName}:pending`, score, id);
|
|
63
|
+
pipeline.publish(`queue:${queueName}:updates`, 'new_job');
|
|
64
|
+
await pipeline.exec();
|
|
65
|
+
logger_1.logger.info(existingJob ? 'Job updated/retried successfully' : 'Job enqueued successfully', { queue: queueName, jobId: id, priority });
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
queue: queueName,
|
|
69
|
+
payload,
|
|
70
|
+
status: 'pending',
|
|
71
|
+
priority,
|
|
72
|
+
attempts: 0,
|
|
73
|
+
maxAttempts,
|
|
74
|
+
runAt
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async claimJob(queueName, workerId) {
|
|
78
|
+
const pendingKey = `queue:${queueName}:pending`;
|
|
79
|
+
const processingKey = `queue:${queueName}:processing`;
|
|
80
|
+
const currentTime = Date.now();
|
|
81
|
+
const lockTimeoutMs = this.options.lockTimeoutMs || 300000;
|
|
82
|
+
// Lua script to atomically find and claim a job (pending or timed out)
|
|
83
|
+
const claimScript = `
|
|
84
|
+
local pending_key = KEYS[1]
|
|
85
|
+
local processing_key = KEYS[2]
|
|
86
|
+
local current_time = tonumber(ARGV[1])
|
|
87
|
+
local worker_id = ARGV[2]
|
|
88
|
+
local lock_timeout_ms = tonumber(ARGV[3])
|
|
89
|
+
|
|
90
|
+
local job_id = nil
|
|
91
|
+
local from_processing = false
|
|
92
|
+
|
|
93
|
+
-- 1. Try to get the earliest pending job
|
|
94
|
+
local jobs = redis.call('ZRANGE', pending_key, 0, 0)
|
|
95
|
+
if #jobs > 0 then
|
|
96
|
+
local p_job_id = jobs[1]
|
|
97
|
+
local run_at = tonumber(redis.call('HGET', 'job:' .. p_job_id, 'run_at'))
|
|
98
|
+
if run_at and run_at <= current_time then
|
|
99
|
+
job_id = p_job_id
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
-- 2. If no pending job is available, check for timed-out processing jobs
|
|
104
|
+
if not job_id then
|
|
105
|
+
local timed_out_jobs = redis.call('ZRANGEBYSCORE', processing_key, 0, current_time - lock_timeout_ms, 'LIMIT', 0, 1)
|
|
106
|
+
if #timed_out_jobs > 0 then
|
|
107
|
+
job_id = timed_out_jobs[1]
|
|
108
|
+
from_processing = true
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if not job_id then
|
|
113
|
+
return nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
-- Move or update processing set score
|
|
117
|
+
if from_processing then
|
|
118
|
+
redis.call('ZADD', processing_key, current_time, job_id)
|
|
119
|
+
else
|
|
120
|
+
redis.call('ZREM', pending_key, job_id)
|
|
121
|
+
redis.call('ZADD', processing_key, current_time, job_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
-- Update job hash status and worker info
|
|
125
|
+
local job_key = 'job:' .. job_id
|
|
126
|
+
redis.call('HMSET', job_key, 'status', 'processing', 'worker_id', worker_id, 'locked_at', current_time)
|
|
127
|
+
redis.call('HINCRBY', job_key, 'attempts', 1)
|
|
128
|
+
|
|
129
|
+
return job_id
|
|
130
|
+
`;
|
|
131
|
+
const jobIdRaw = await this.redis.eval(claimScript, 2, pendingKey, processingKey, currentTime.toString(), workerId, lockTimeoutMs.toString());
|
|
132
|
+
if (!jobIdRaw) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const jobId = typeof jobIdRaw === 'string' ? jobIdRaw : jobIdRaw.toString();
|
|
136
|
+
const job = await this.getJob(jobId);
|
|
137
|
+
if (job) {
|
|
138
|
+
logger_1.logger.info('Job claimed successfully', { queue: queueName, jobId: job.id, workerId });
|
|
139
|
+
}
|
|
140
|
+
return job;
|
|
141
|
+
}
|
|
142
|
+
async completeJob(jobId) {
|
|
143
|
+
const jobKey = `job:${jobId}`;
|
|
144
|
+
const queue = await this.redis.hget(jobKey, 'queue');
|
|
145
|
+
if (!queue) {
|
|
146
|
+
logger_1.logger.warn('Tried to complete job that does not exist or missing queue', { jobId });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const processingKey = `queue:${queue}:processing`;
|
|
150
|
+
const currentTime = Date.now();
|
|
151
|
+
await this.redis.hmset(jobKey, {
|
|
152
|
+
status: 'completed',
|
|
153
|
+
completed_at: currentTime.toString(),
|
|
154
|
+
});
|
|
155
|
+
await this.redis.zrem(processingKey, jobId);
|
|
156
|
+
logger_1.logger.info('Job completed successfully', { queue, jobId });
|
|
157
|
+
if (this.options.completedJobTtlMs && this.options.completedJobTtlMs > 0) {
|
|
158
|
+
const ttlSecs = Math.max(1, Math.floor(this.options.completedJobTtlMs / 1000));
|
|
159
|
+
await this.redis.expire(jobKey, ttlSecs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async failJob(jobId, error) {
|
|
163
|
+
const jobKey = `job:${jobId}`;
|
|
164
|
+
const jobData = await this.redis.hgetall(jobKey);
|
|
165
|
+
if (!jobData || !jobData.queue) {
|
|
166
|
+
logger_1.logger.warn('Tried to fail job that does not exist or missing queue', { jobId, error });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const queue = jobData.queue;
|
|
170
|
+
const attempts = Number.isNaN(parseInt(jobData.attempts || '0', 10)) ? 0 : parseInt(jobData.attempts || '0', 10);
|
|
171
|
+
const maxAttempts = Number.isNaN(parseInt(jobData.max_attempts || '3', 10)) ? 0 : parseInt(jobData.max_attempts || '3', 10);
|
|
172
|
+
const currentTime = Date.now();
|
|
173
|
+
if (attempts < maxAttempts) {
|
|
174
|
+
// Retry: Set status back to pending, delay retry by 5 seconds
|
|
175
|
+
const runAtTime = currentTime + 5000;
|
|
176
|
+
const priority = Number.isNaN(parseInt(jobData.priority || '0', 10)) ? 0 : parseInt(jobData.priority || '0', 10);
|
|
177
|
+
const score = runAtTime - (priority * 10000000000);
|
|
178
|
+
await this.redis.hmset(jobKey, {
|
|
179
|
+
status: 'pending',
|
|
180
|
+
run_at: runAtTime.toString(),
|
|
181
|
+
error,
|
|
182
|
+
});
|
|
183
|
+
await this.redis.hdel(jobKey, 'worker_id', 'locked_at');
|
|
184
|
+
await this.redis.zrem(`queue:${queue}:processing`, jobId);
|
|
185
|
+
await this.redis.zadd(`queue:${queue}:pending`, score, jobId);
|
|
186
|
+
logger_1.logger.warn('Job execution failed, scheduling retry', { queue, jobId, error, attempt: attempts, maxAttempts });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Terminal Failure
|
|
190
|
+
await this.redis.hmset(jobKey, {
|
|
191
|
+
status: 'failed',
|
|
192
|
+
failed_at: currentTime.toString(),
|
|
193
|
+
error,
|
|
194
|
+
});
|
|
195
|
+
await this.redis.zrem(`queue:${queue}:processing`, jobId);
|
|
196
|
+
logger_1.logger.error('Job failed terminally', { queue, jobId, error, attempts });
|
|
197
|
+
if (this.options.failedJobTtlMs && this.options.failedJobTtlMs > 0) {
|
|
198
|
+
const ttlSecs = Math.max(1, Math.floor(this.options.failedJobTtlMs / 1000));
|
|
199
|
+
await this.redis.expire(jobKey, ttlSecs);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async getJob(jobId) {
|
|
204
|
+
const jobKey = `job:${jobId}`;
|
|
205
|
+
const data = await this.redis.hgetall(jobKey);
|
|
206
|
+
if (!data || Object.keys(data).length === 0)
|
|
207
|
+
return null;
|
|
208
|
+
// Fast payload fetching using buffer to preserve binary signature of MessagePack
|
|
209
|
+
const bufferPayload = await this.redis.hgetBuffer(jobKey, 'payload');
|
|
210
|
+
let payload = null;
|
|
211
|
+
if (bufferPayload) {
|
|
212
|
+
try {
|
|
213
|
+
payload = (0, msgpack_1.deserialize)(bufferPayload);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
payload = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const safeInt = (v, fallback = 0) => {
|
|
220
|
+
const n = parseInt(v ?? String(fallback), 10);
|
|
221
|
+
return Number.isNaN(n) ? fallback : n;
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
id: data.id,
|
|
225
|
+
queue: data.queue,
|
|
226
|
+
payload,
|
|
227
|
+
status: data.status || 'pending',
|
|
228
|
+
priority: safeInt(data.priority, 0),
|
|
229
|
+
attempts: safeInt(data.attempts, 0),
|
|
230
|
+
maxAttempts: safeInt(data.max_attempts, 3),
|
|
231
|
+
runAt: new Date(safeInt(data.run_at, Date.now())),
|
|
232
|
+
lockedAt: data.locked_at ? new Date(safeInt(data.locked_at, 0)) : undefined,
|
|
233
|
+
completedAt: data.completed_at ? new Date(safeInt(data.completed_at, 0)) : undefined,
|
|
234
|
+
failedAt: data.failed_at ? new Date(safeInt(data.failed_at, 0)) : undefined,
|
|
235
|
+
error: data.error || undefined,
|
|
236
|
+
workerId: data.worker_id || undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
await this.redis.quit();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.RedisQueueAdapter = RedisQueueAdapter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|