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 ADDED
@@ -0,0 +1,246 @@
1
+ # ☄️ queue-tool (Node.js Adapter)
2
+
3
+ The high-performance, cross-language, database-agnostic task queue for Node.js and TypeScript. Automatically supports binary MessagePack serialization and atomic claims across PostgreSQL, Redis, and MongoDB.
4
+
5
+ ---
6
+
7
+ ## 📦 Installation
8
+
9
+ ```bash
10
+ npm install queue-tool
11
+ # or
12
+ pnpm add queue-tool
13
+ # or
14
+ yarn add queue-tool
15
+ ```
16
+
17
+ Depending on which adapter you use, install the corresponding peer dependency:
18
+ - **PostgreSQL**: `npm install pg @types/pg`
19
+ - **Redis**: `npm install ioredis`
20
+ - **MongoDB**: `npm install mongodb`
21
+
22
+ ---
23
+
24
+ ## 🚀 Basic Usage (Node.js / TypeScript)
25
+
26
+ ### 1. Initialize Adapter
27
+ Use the `QueueFactory` to automatically instantiate the correct adapter based on your connection string:
28
+
29
+ ```typescript
30
+ import { QueueFactory } from 'queue-tool';
31
+
32
+ // Automatically instantiates PostgresQueueAdapter
33
+ const adapter = await QueueFactory.create('postgresql://postgres:password@localhost:5432/queue_tool', {
34
+ completedJobTtlMs: 3600000, // Retain completed jobs for 1 hour
35
+ failedJobTtlMs: 86400000, // Retain failed jobs for 24 hours
36
+ lockTimeoutMs: 300000, // Automatically reclaim stuck jobs after 5 mins
37
+ pruneIntervalMs: 60000, // Run background pruning check every 1 min
38
+ });
39
+ ```
40
+
41
+ ### 2. Enqueue Jobs
42
+ ```typescript
43
+ const job = await adapter.enqueue('image-processing', {
44
+ imageId: 'img-10293',
45
+ operations: ['resize', 'compress'],
46
+ }, {
47
+ priority: 10, // Higher priority jobs are claimed first
48
+ maxAttempts: 3, // Auto-retry up to 3 times before failing
49
+ delayMs: 5000 // Wait 5 seconds before making job available
50
+ });
51
+ ```
52
+
53
+ ### 3. Claim and Complete Jobs
54
+ ```typescript
55
+ const job = await adapter.claimJob('image-processing', 'worker-node-1');
56
+
57
+ if (job) {
58
+ try {
59
+ console.log(`Processing payload:`, job.payload);
60
+ // ... do processing ...
61
+
62
+ await adapter.completeJob(job.id);
63
+ } catch (error) {
64
+ // Moves back to pending (rescheduled in 5 seconds) or marks failed if attempts exhausted
65
+ await adapter.failJob(job.id, error.message);
66
+ }
67
+ }
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 🦅 NestJS Integration Guide
73
+
74
+ To cleanly integrate `queue-tool` inside a **NestJS** application, you can create a custom dynamic module.
75
+
76
+ ### 1. Create the Queue Module
77
+
78
+ Create a file named `queue.module.ts`:
79
+
80
+ ```typescript
81
+ import { Module, DynamicModule, Global } from '@nestjs/common';
82
+ import { QueueFactory } from 'queue-tool';
83
+ import { QueueAdapterOptions } from 'queue-tool/dist/types';
84
+
85
+ export const QUEUE_ADAPTER = 'QUEUE_ADAPTER';
86
+
87
+ @Global()
88
+ @Module({})
89
+ export class QueueModule {
90
+ static register(connectionString: string, options?: QueueAdapterOptions): DynamicModule {
91
+ const provider = {
92
+ provide: QUEUE_ADAPTER,
93
+ useFactory: async () => {
94
+ const adapter = await QueueFactory.create(connectionString, options);
95
+ return adapter;
96
+ },
97
+ };
98
+
99
+ return {
100
+ module: QueueModule,
101
+ providers: [provider],
102
+ exports: [provider],
103
+ };
104
+ }
105
+ }
106
+ ```
107
+
108
+ ### 2. Import Module in App Module
109
+
110
+ ```typescript
111
+ import { Module } from '@nestjs/common';
112
+ import { QueueModule } from './queue.module';
113
+
114
+ @Module({
115
+ imports: [
116
+ QueueModule.register('redis://localhost:6379', {
117
+ lockTimeoutMs: 300000, // 5 minutes
118
+ }),
119
+ ],
120
+ })
121
+ export class AppModule {}
122
+ ```
123
+
124
+ ### 3. Inject and Use in a Service
125
+
126
+ ```typescript
127
+ import { Injectable, Inject, OnModuleDestroy } from '@nestjs/common';
128
+ import { QUEUE_ADAPTER } from './queue.module';
129
+ import { IQueueAdapter } from 'queue-tool/dist/types';
130
+
131
+ @Injectable()
132
+ export class TaskService implements OnModuleDestroy {
133
+ constructor(
134
+ @Inject(QUEUE_ADAPTER) private readonly queueAdapter: IQueueAdapter,
135
+ ) {}
136
+
137
+ async createProcessTask(data: any) {
138
+ return this.queueAdapter.enqueue('my-task-queue', data, { priority: 5 });
139
+ }
140
+
141
+ async onModuleDestroy() {
142
+ await this.queueAdapter.close();
143
+ }
144
+ }
145
+ ```
146
+
147
+ ### 4. Create a Background Worker with high-level Worker
148
+
149
+ ```typescript
150
+ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
151
+ import { QUEUE_ADAPTER } from './queue.module';
152
+ import { IQueueAdapter, Worker } from 'queue-tool';
153
+
154
+ @Injectable()
155
+ export class WorkerService implements OnModuleInit, OnModuleDestroy {
156
+ private worker!: Worker;
157
+
158
+ constructor(
159
+ @Inject(QUEUE_ADAPTER) private readonly queueAdapter: IQueueAdapter,
160
+ ) {}
161
+
162
+ async onModuleInit() {
163
+ this.worker = new Worker(
164
+ 'my-task-queue',
165
+ this.queueAdapter,
166
+ async (job) => {
167
+ console.log('Processing job payload:', job.payload);
168
+ // Your job execution logic here...
169
+ },
170
+ { concurrency: 2, pollIntervalMs: 500 }
171
+ );
172
+
173
+ await this.worker.start();
174
+ }
175
+
176
+ async onModuleDestroy() {
177
+ await this.worker.stop();
178
+ }
179
+ }
180
+ ```
181
+
182
+ ---
183
+
184
+ ## 🚂 ExpressJS Integration Guide
185
+
186
+ Create a background worker and API endpoints in your Express application:
187
+
188
+ ```typescript
189
+ import express from 'express';
190
+ import { QueueFactory, Worker } from 'queue-tool';
191
+
192
+ const app = express();
193
+ app.use(express.json());
194
+
195
+ const startApp = async () => {
196
+ // 1. Initialize queue adapter
197
+ const adapter = await QueueFactory.create('redis://localhost:6379');
198
+
199
+ // 2. Start worker to process background tasks
200
+ const worker = new Worker('express-tasks', adapter, async (job) => {
201
+ console.log(`Processing background job ${job.id}:`, job.payload);
202
+ });
203
+ await worker.start();
204
+
205
+ // 3. Define routes
206
+ app.post('/enqueue', async (req, res) => {
207
+ const job = await adapter.enqueue('express-tasks', req.body);
208
+ res.json({ status: 'enqueued', jobId: job.id });
209
+ });
210
+
211
+ app.listen(3000, () => console.log('Server running on port 3000'));
212
+ };
213
+
214
+ startApp();
215
+ ```
216
+
217
+ ---
218
+
219
+ ## ☄️ Hono Integration Guide
220
+
221
+ Hono works great with async background workers. Define workers at startup:
222
+
223
+ ```typescript
224
+ import { Hono } from 'hono';
225
+ import { QueueFactory, Worker } from 'queue-tool';
226
+
227
+ const app = new Hono();
228
+
229
+ // Instantiate adapter
230
+ const adapter = await QueueFactory.create('mongodb://localhost:27017/queue_tool');
231
+
232
+ // Start worker
233
+ const worker = new Worker('hono-tasks', adapter, async (job) => {
234
+ console.log(`Hono task running: ${job.id}`);
235
+ });
236
+ await worker.start();
237
+
238
+ app.post('/enqueue', async (c) => {
239
+ const body = await c.req.json();
240
+ const job = await adapter.enqueue('hono-tasks', body);
241
+ return c.json({ status: 'enqueued', jobId: job.id });
242
+ });
243
+
244
+ export default app;
245
+ ```
246
+
@@ -0,0 +1,14 @@
1
+ import { IQueueAdapter, Job, EnqueueOptions, QueueAdapterOptions } from '../types';
2
+ export declare class MongoQueueAdapter implements IQueueAdapter {
3
+ private client;
4
+ private collection;
5
+ private options;
6
+ constructor(connectionString: string, dbName?: string, options?: QueueAdapterOptions);
7
+ init(): Promise<void>;
8
+ enqueue<T>(queueName: string, payload: T, options?: EnqueueOptions): Promise<Job<T>>;
9
+ claimJob(queueName: string, workerId: string): Promise<Job | null>;
10
+ completeJob(jobId: string): Promise<void>;
11
+ failJob(jobId: string, error: string): Promise<void>;
12
+ close(): Promise<void>;
13
+ private mapDocToJob;
14
+ }
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MongoQueueAdapter = void 0;
4
+ const mongodb_1 = require("mongodb");
5
+ const msgpack_1 = require("../utils/msgpack");
6
+ const crypto_1 = require("crypto");
7
+ const logger_1 = require("../utils/logger");
8
+ class MongoQueueAdapter {
9
+ client;
10
+ collection;
11
+ options;
12
+ constructor(connectionString, dbName = 'queue_tool', options) {
13
+ this.client = new mongodb_1.MongoClient(connectionString);
14
+ this.collection = this.client.db(dbName).collection('jobs');
15
+ this.options = {
16
+ completedJobTtlMs: options?.completedJobTtlMs ?? 3600000, // 1 hour
17
+ failedJobTtlMs: options?.failedJobTtlMs ?? 86400000, // 24 hours
18
+ lockTimeoutMs: options?.lockTimeoutMs ?? 300000, // 5 minutes
19
+ };
20
+ }
21
+ async init() {
22
+ try {
23
+ await this.client.connect();
24
+ await this.client.db().command({ ping: 1 });
25
+ logger_1.logger.info('MongoDB connection verified successfully');
26
+ }
27
+ catch (e) {
28
+ logger_1.logger.error('MongoDB connection health check failed', { error: e.message });
29
+ throw new Error(`MongoDB connection health check failed: ${e.message}`);
30
+ }
31
+ // Enforce compound index for extremely fast claiming performance
32
+ await this.collection.createIndex({ queue: 1, status: 1, priority: -1, runAt: 1 }, { name: 'idx_jobs_claim' });
33
+ // Native TTL index to automatically purge expired documents
34
+ await this.collection.createIndex({ expiresAt: 1 }, { name: 'idx_jobs_ttl', expireAfterSeconds: 0 });
35
+ }
36
+ async enqueue(queueName, payload, options) {
37
+ const id = options?.id ?? (0, crypto_1.randomUUID)();
38
+ const serializedPayload = (0, msgpack_1.serialize)(payload);
39
+ const priority = options?.priority ?? 0;
40
+ const maxAttempts = options?.maxAttempts ?? 3;
41
+ if (priority < -1000 || priority > 1000) {
42
+ logger_1.logger.error('Invalid job priority', { priority, queue: queueName });
43
+ throw new Error('Priority must be in the range [-1000, 1000]');
44
+ }
45
+ let runAt = options?.runAt ?? new Date();
46
+ if (options?.delayMs) {
47
+ runAt = new Date(Date.now() + options.delayMs);
48
+ }
49
+ const existingDoc = await this.collection.findOne({ _id: id });
50
+ const existingJob = !!existingDoc;
51
+ const doc = {
52
+ _id: id,
53
+ queue: queueName,
54
+ payload: new mongodb_1.Binary(serializedPayload),
55
+ status: 'pending',
56
+ priority,
57
+ attempts: 0,
58
+ maxAttempts,
59
+ runAt,
60
+ lockedAt: null,
61
+ completedAt: null,
62
+ failedAt: null,
63
+ expiresAt: null,
64
+ error: null,
65
+ workerId: null,
66
+ };
67
+ const res = await this.collection.findOneAndUpdate({ _id: id }, { $set: doc }, { upsert: true, returnDocument: 'after' });
68
+ logger_1.logger.info(existingJob ? 'Job updated/retried successfully' : 'Job enqueued successfully', { queue: queueName, jobId: id, priority });
69
+ return this.mapDocToJob(res);
70
+ }
71
+ async claimJob(queueName, workerId) {
72
+ const now = new Date();
73
+ const lockTimeoutMs = this.options.lockTimeoutMs ?? 300000;
74
+ const lockTimeoutDate = new Date(now.getTime() - lockTimeoutMs);
75
+ const res = await this.collection.findOneAndUpdate({
76
+ queue: queueName,
77
+ $or: [
78
+ { status: 'pending', runAt: { $lte: now } },
79
+ { status: 'processing', lockedAt: { $lte: lockTimeoutDate } }
80
+ ]
81
+ }, {
82
+ $set: {
83
+ status: 'processing',
84
+ workerId: workerId,
85
+ lockedAt: now
86
+ },
87
+ $inc: {
88
+ attempts: 1
89
+ }
90
+ }, {
91
+ sort: { priority: -1, runAt: 1 },
92
+ returnDocument: 'after'
93
+ });
94
+ if (!res) {
95
+ return null;
96
+ }
97
+ const job = this.mapDocToJob(res);
98
+ logger_1.logger.info('Job claimed successfully', { queue: queueName, jobId: job.id, workerId });
99
+ return job;
100
+ }
101
+ async completeJob(jobId) {
102
+ const res = await this.collection.findOneAndUpdate({ _id: jobId }, {
103
+ $set: {
104
+ status: 'completed',
105
+ completedAt: new Date(),
106
+ expiresAt: null
107
+ }
108
+ }, { returnDocument: 'after' });
109
+ if (!res) {
110
+ logger_1.logger.warn('Tried to complete job that does not exist', { jobId });
111
+ return;
112
+ }
113
+ logger_1.logger.info('Job completed successfully', { queue: res.queue, jobId });
114
+ }
115
+ async failJob(jobId, error) {
116
+ const jobDoc = await this.collection.findOne({ _id: jobId });
117
+ if (!jobDoc) {
118
+ logger_1.logger.warn('Tried to fail job that does not exist', { jobId, error });
119
+ return;
120
+ }
121
+ const { queue, attempts, maxAttempts } = jobDoc;
122
+ if (attempts < maxAttempts) {
123
+ // Retry: Set status back to pending, delay retry by 5 seconds
124
+ await this.collection.updateOne({ _id: jobId }, {
125
+ $set: {
126
+ status: 'pending',
127
+ runAt: new Date(Date.now() + 5000),
128
+ error,
129
+ workerId: null,
130
+ lockedAt: null
131
+ }
132
+ });
133
+ logger_1.logger.warn('Job execution failed, scheduling retry', { queue, jobId, error, attempt: attempts, maxAttempts });
134
+ }
135
+ else {
136
+ // Terminal Failure
137
+ const expiresAt = new Date(Date.now() + (this.options.failedJobTtlMs || 86400000));
138
+ await this.collection.updateOne({ _id: jobId }, {
139
+ $set: {
140
+ status: 'failed',
141
+ failedAt: new Date(),
142
+ expiresAt: expiresAt,
143
+ error
144
+ }
145
+ });
146
+ logger_1.logger.error('Job failed terminally', { queue, jobId, error, attempts });
147
+ }
148
+ }
149
+ async close() {
150
+ logger_1.logger.info('Closing MongoDB adapter client pool');
151
+ await this.client.close();
152
+ }
153
+ mapDocToJob(doc) {
154
+ const payloadBuffer = doc.payload.buffer ? doc.payload.buffer : Buffer.from(doc.payload);
155
+ return {
156
+ id: doc._id,
157
+ queue: doc.queue,
158
+ payload: (0, msgpack_1.deserialize)(payloadBuffer),
159
+ status: doc.status,
160
+ priority: doc.priority,
161
+ attempts: doc.attempts,
162
+ maxAttempts: doc.maxAttempts,
163
+ runAt: doc.runAt,
164
+ lockedAt: doc.lockedAt || undefined,
165
+ completedAt: doc.completedAt || undefined,
166
+ failedAt: doc.failedAt || undefined,
167
+ expiresAt: doc.expiresAt || undefined,
168
+ error: doc.error || undefined,
169
+ workerId: doc.workerId || undefined,
170
+ };
171
+ }
172
+ }
173
+ exports.MongoQueueAdapter = MongoQueueAdapter;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const mongodb_1 = require("./mongodb");
4
+ const mongodb_2 = require("mongodb");
5
+ describe('MongoQueueAdapter Integration Tests', () => {
6
+ let adapter;
7
+ let mongoClient;
8
+ const queueName = 'test-mongo-integration-queue';
9
+ const connectionString = 'mongodb://localhost:27017';
10
+ const dbName = 'queue_tool';
11
+ let isAvailable = true;
12
+ beforeAll(async () => {
13
+ try {
14
+ mongoClient = new mongodb_2.MongoClient(connectionString);
15
+ await mongoClient.connect();
16
+ adapter = new mongodb_1.MongoQueueAdapter(connectionString, dbName);
17
+ await adapter.init();
18
+ }
19
+ catch (err) {
20
+ isAvailable = false;
21
+ console.warn('MongoDB is not running. Skipping MongoDB integration tests.');
22
+ }
23
+ });
24
+ afterAll(async () => {
25
+ if (!isAvailable)
26
+ return;
27
+ await adapter.close();
28
+ await mongoClient.close();
29
+ });
30
+ beforeEach(async () => {
31
+ if (!isAvailable)
32
+ return;
33
+ // Clean test queue jobs
34
+ await mongoClient.db(dbName).collection('jobs').deleteMany({ queue: queueName });
35
+ });
36
+ test('should enqueue a job successfully', async () => {
37
+ if (!isAvailable)
38
+ return;
39
+ const payload = { task: 'test-mongo-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-mongo', step: 1 };
57
+ const customId = 'custom-mongo-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-mongo-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-mongo', 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 doc = await mongoClient.db(dbName).collection('jobs').findOne({ _id: customId });
76
+ expect(doc).not.toBeNull();
77
+ expect(doc.status).toBe('pending');
78
+ expect(doc.attempts).toBe(0);
79
+ expect(doc.priority).toBe(8);
80
+ });
81
+ test('should claim and complete a job successfully', async () => {
82
+ if (!isAvailable)
83
+ return;
84
+ const payload = { task: 'test-mongo-claim', value: 200 };
85
+ const job = await adapter.enqueue(queueName, payload);
86
+ const claimedJob = await adapter.claimJob(queueName, 'worker-test-mongo-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 doc = await mongoClient.db(dbName).collection('jobs').findOne({ _id: job.id });
93
+ expect(doc).not.toBeNull();
94
+ expect(doc.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-mongo-retry' };
100
+ const job = await adapter.enqueue(queueName, payload, { maxAttempts: 2 });
101
+ const claimedJob = await adapter.claimJob(queueName, 'worker-test-mongo-2');
102
+ expect(claimedJob).not.toBeNull();
103
+ await adapter.failJob(claimedJob.id, 'First test error mongo');
104
+ const doc1 = await mongoClient.db(dbName).collection('jobs').findOne({ _id: job.id });
105
+ expect(doc1.status).toBe('pending');
106
+ expect(doc1.error).toBe('First test error mongo');
107
+ // Reset runAt to the past so it can be claimed again immediately without waiting 5 seconds
108
+ await mongoClient.db(dbName).collection('jobs').updateOne({ _id: job.id }, { $set: { runAt: new Date(Date.now() - 60000) } });
109
+ // Claim again (2nd attempt)
110
+ const claimedJob2 = await adapter.claimJob(queueName, 'worker-test-mongo-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 mongo');
115
+ const doc2 = await mongoClient.db(dbName).collection('jobs').findOne({ _id: job.id });
116
+ expect(doc2.status).toBe('failed');
117
+ expect(doc2.error).toBe('Second test error mongo');
118
+ });
119
+ });
@@ -0,0 +1,15 @@
1
+ import { IQueueAdapter, Job, EnqueueOptions, QueueAdapterOptions } from '../types';
2
+ export declare class PostgresQueueAdapter implements IQueueAdapter {
3
+ private pool;
4
+ private options;
5
+ private pruneTimer?;
6
+ constructor(connectionString: string, options?: QueueAdapterOptions);
7
+ init(): Promise<void>;
8
+ enqueue<T>(queueName: string, payload: T, options?: EnqueueOptions): Promise<Job<T>>;
9
+ claimJob(queueName: string, workerId: string): Promise<Job | null>;
10
+ completeJob(jobId: string): Promise<void>;
11
+ failJob(jobId: string, error: string): Promise<void>;
12
+ listenToQueue(queueName: string, callback: () => void): Promise<() => Promise<void>>;
13
+ close(): Promise<void>;
14
+ private mapRowToJob;
15
+ }