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.
@@ -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 {};