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
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
|
+
}
|