@nebutra/queue 0.1.0 → 0.1.2
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 +69 -2
- package/dist/factory.d.ts +42 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +156 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/middleware/qstash-verify.d.ts +16 -0
- package/dist/middleware/qstash-verify.d.ts.map +1 -0
- package/dist/middleware/qstash-verify.js +87 -0
- package/dist/providers/bullmq.d.ts +32 -0
- package/dist/providers/bullmq.d.ts.map +1 -0
- package/dist/providers/bullmq.js +258 -0
- package/dist/providers/memory.d.ts +23 -0
- package/dist/providers/memory.d.ts.map +1 -0
- package/dist/providers/memory.js +220 -0
- package/dist/providers/qstash.d.ts +26 -0
- package/dist/providers/qstash.d.ts.map +1 -0
- package/dist/providers/qstash.js +226 -0
- package/dist/providers/sqs.d.ts +18 -0
- package/dist/providers/sqs.d.ts.map +1 -0
- package/dist/providers/sqs.js +197 -0
- package/dist/queuebase-webhook.d.ts +45 -0
- package/dist/queuebase-webhook.d.ts.map +1 -0
- package/dist/queuebase-webhook.js +113 -0
- package/dist/queuebase.d.ts +27 -0
- package/dist/queuebase.d.ts.map +1 -0
- package/dist/queuebase.js +48 -0
- package/dist/scheduled/index.d.ts +15 -0
- package/dist/scheduled/index.d.ts.map +1 -0
- package/dist/scheduled/index.js +25 -0
- package/dist/scheduled/jobs/invitation-cleanup.d.ts +40 -0
- package/dist/scheduled/jobs/invitation-cleanup.d.ts.map +1 -0
- package/dist/scheduled/jobs/invitation-cleanup.js +44 -0
- package/dist/scheduled/jobs/session-cleanup.d.ts +24 -0
- package/dist/scheduled/jobs/session-cleanup.d.ts.map +1 -0
- package/dist/scheduled/jobs/session-cleanup.js +32 -0
- package/dist/scheduled/scheduler.d.ts +44 -0
- package/dist/scheduled/scheduler.d.ts.map +1 -0
- package/dist/scheduled/scheduler.js +52 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/package.json +19 -6
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
import { Queue, Worker } from "bullmq";
|
|
3
|
+
import IORedis from "ioredis";
|
|
4
|
+
export function toBullMQDeadLetterJob(queue, job) {
|
|
5
|
+
const maxRetries = job.opts.attempts ?? job.data.options?.maxRetries ?? 3;
|
|
6
|
+
if (job.attemptsMade < maxRetries)
|
|
7
|
+
return undefined;
|
|
8
|
+
return {
|
|
9
|
+
id: job.id ?? job.data.id,
|
|
10
|
+
queue,
|
|
11
|
+
type: job.data.type,
|
|
12
|
+
originalJob: job.data,
|
|
13
|
+
attempts: job.attemptsMade,
|
|
14
|
+
maxRetries,
|
|
15
|
+
failedReason: job.failedReason ?? "Job failed without a provider reason",
|
|
16
|
+
provider: "bullmq",
|
|
17
|
+
failedAt: new Date(job.finishedOn ?? Date.now()).toISOString(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// BullMQ Provider — self-hosted Redis queue backend
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Uses BullMQ (the successor to Bull) for job queuing over a standard Redis
|
|
23
|
+
// instance. Ideal for customers who self-host Redis or need features like
|
|
24
|
+
// priority queues, rate limiting, and the Bull Board dashboard.
|
|
25
|
+
//
|
|
26
|
+
// Architecture:
|
|
27
|
+
// enqueue() → Redis LPUSH via BullMQ Queue
|
|
28
|
+
// registerHandler → BullMQ Worker (long-polling BRPOPLPUSH)
|
|
29
|
+
// getJobStatus → Redis HGET on the job hash
|
|
30
|
+
// =============================================================================
|
|
31
|
+
export class BullMQProvider {
|
|
32
|
+
name = "bullmq";
|
|
33
|
+
connection;
|
|
34
|
+
queues = new Map();
|
|
35
|
+
workers = new Map();
|
|
36
|
+
handlers = new Map();
|
|
37
|
+
prefix;
|
|
38
|
+
concurrency;
|
|
39
|
+
constructor(config) {
|
|
40
|
+
const redisUrl = config.redisUrl ?? process.env.REDIS_URL;
|
|
41
|
+
if (!redisUrl) {
|
|
42
|
+
throw new Error("Redis URL not configured. Set REDIS_URL env var or pass `redisUrl` in config.");
|
|
43
|
+
}
|
|
44
|
+
this.prefix = config.prefix ?? "nebutra:queue";
|
|
45
|
+
this.concurrency = config.concurrency ?? 5;
|
|
46
|
+
this.connection = new IORedis(redisUrl, {
|
|
47
|
+
maxRetriesPerRequest: null, // Required by BullMQ
|
|
48
|
+
enableReadyCheck: false,
|
|
49
|
+
lazyConnect: true,
|
|
50
|
+
});
|
|
51
|
+
this.connection.on("error", (err) => {
|
|
52
|
+
logger.error("[queue:bullmq] Redis connection error", {
|
|
53
|
+
error: err.message,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
logger.info("[queue:bullmq] Provider initialised", {
|
|
57
|
+
prefix: this.prefix,
|
|
58
|
+
concurrency: this.concurrency,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// ── Internal helpers ────────────────────────────────────────────────────
|
|
62
|
+
getOrCreateQueue(queueName) {
|
|
63
|
+
let queue = this.queues.get(queueName);
|
|
64
|
+
if (!queue) {
|
|
65
|
+
queue = new Queue(queueName, {
|
|
66
|
+
connection: this.connection.duplicate(),
|
|
67
|
+
prefix: this.prefix,
|
|
68
|
+
defaultJobOptions: {
|
|
69
|
+
attempts: 3,
|
|
70
|
+
backoff: { type: "exponential", delay: 1000 },
|
|
71
|
+
removeOnComplete: { count: 1000 },
|
|
72
|
+
removeOnFail: { count: 5000 },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
this.queues.set(queueName, queue);
|
|
76
|
+
}
|
|
77
|
+
return queue;
|
|
78
|
+
}
|
|
79
|
+
// ── Enqueue ─────────────────────────────────────────────────────────────
|
|
80
|
+
async enqueue(job) {
|
|
81
|
+
const queue = this.getOrCreateQueue(job.queue);
|
|
82
|
+
try {
|
|
83
|
+
const bullJob = await queue.add(job.type, job, {
|
|
84
|
+
jobId: job.id,
|
|
85
|
+
...(job.options?.delaySec ? { delay: job.options.delaySec * 1000 } : {}),
|
|
86
|
+
attempts: job.options?.maxRetries ?? 3,
|
|
87
|
+
...(job.options?.priority !== undefined ? { priority: job.options.priority } : {}),
|
|
88
|
+
...(job.options?.cron ? { repeat: { pattern: job.options.cron } } : {}),
|
|
89
|
+
});
|
|
90
|
+
logger.info("[queue:bullmq] Job enqueued", {
|
|
91
|
+
jobId: bullJob.id,
|
|
92
|
+
queue: job.queue,
|
|
93
|
+
type: job.type,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
jobId: bullJob.id ?? job.id,
|
|
97
|
+
accepted: true,
|
|
98
|
+
provider: "bullmq",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
logger.error("[queue:bullmq] Failed to enqueue job", {
|
|
103
|
+
jobId: job.id,
|
|
104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
105
|
+
});
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async enqueueBatch(jobs) {
|
|
110
|
+
// Group jobs by queue name for efficient bulk add
|
|
111
|
+
const grouped = new Map();
|
|
112
|
+
for (const job of jobs) {
|
|
113
|
+
const list = grouped.get(job.queue) ?? [];
|
|
114
|
+
list.push(job);
|
|
115
|
+
grouped.set(job.queue, list);
|
|
116
|
+
}
|
|
117
|
+
const results = [];
|
|
118
|
+
for (const [queueName, queueJobs] of grouped) {
|
|
119
|
+
const queue = this.getOrCreateQueue(queueName);
|
|
120
|
+
const bulkData = queueJobs.map((job) => ({
|
|
121
|
+
name: job.type,
|
|
122
|
+
data: job,
|
|
123
|
+
opts: {
|
|
124
|
+
jobId: job.id,
|
|
125
|
+
...(job.options?.delaySec ? { delay: job.options.delaySec * 1000 } : {}),
|
|
126
|
+
attempts: job.options?.maxRetries ?? 3,
|
|
127
|
+
...(job.options?.priority !== undefined ? { priority: job.options.priority } : {}),
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
const bullJobs = await queue.addBulk(bulkData);
|
|
131
|
+
for (let i = 0; i < bullJobs.length; i++) {
|
|
132
|
+
results.push({
|
|
133
|
+
jobId: bullJobs[i]?.id ?? queueJobs[i]?.id ?? "",
|
|
134
|
+
accepted: true,
|
|
135
|
+
provider: "bullmq",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
logger.info("[queue:bullmq] Batch enqueued", { count: jobs.length });
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
// ── Handler Registration ────────────────────────────────────────────────
|
|
143
|
+
registerHandler(queue, type, handler) {
|
|
144
|
+
const key = `${queue}:${type}`;
|
|
145
|
+
this.handlers.set(key, handler);
|
|
146
|
+
// Create or update the worker for this queue
|
|
147
|
+
this.ensureWorker(queue);
|
|
148
|
+
logger.info("[queue:bullmq] Handler registered", { key });
|
|
149
|
+
}
|
|
150
|
+
ensureWorker(queueName) {
|
|
151
|
+
if (this.workers.has(queueName))
|
|
152
|
+
return;
|
|
153
|
+
const worker = new Worker(queueName, async (bullJob) => {
|
|
154
|
+
const payload = bullJob.data;
|
|
155
|
+
const key = `${payload.queue}:${payload.type}`;
|
|
156
|
+
const handler = this.handlers.get(key);
|
|
157
|
+
if (!handler) {
|
|
158
|
+
logger.warn("[queue:bullmq] No handler registered for job type", {
|
|
159
|
+
key,
|
|
160
|
+
jobId: payload.id,
|
|
161
|
+
});
|
|
162
|
+
throw new Error(`No handler registered for ${key}`);
|
|
163
|
+
}
|
|
164
|
+
await handler(payload);
|
|
165
|
+
}, {
|
|
166
|
+
connection: this.connection.duplicate(),
|
|
167
|
+
prefix: this.prefix,
|
|
168
|
+
concurrency: this.concurrency,
|
|
169
|
+
removeOnComplete: { count: 1000 },
|
|
170
|
+
removeOnFail: { count: 5000 },
|
|
171
|
+
});
|
|
172
|
+
worker.on("completed", (job) => {
|
|
173
|
+
logger.info("[queue:bullmq] Job completed", {
|
|
174
|
+
jobId: job.id,
|
|
175
|
+
queue: queueName,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
worker.on("failed", (job, err) => {
|
|
179
|
+
logger.error("[queue:bullmq] Job failed", {
|
|
180
|
+
jobId: job?.id,
|
|
181
|
+
queue: queueName,
|
|
182
|
+
error: err.message,
|
|
183
|
+
attempts: job?.attemptsMade,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
worker.on("error", (err) => {
|
|
187
|
+
logger.error("[queue:bullmq] Worker error", {
|
|
188
|
+
queue: queueName,
|
|
189
|
+
error: err.message,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
this.workers.set(queueName, worker);
|
|
193
|
+
}
|
|
194
|
+
// ── Job Status ──────────────────────────────────────────────────────────
|
|
195
|
+
async getJobStatus(jobId, queue) {
|
|
196
|
+
const q = this.queues.get(queue);
|
|
197
|
+
if (!q)
|
|
198
|
+
return undefined;
|
|
199
|
+
const bullJob = await q.getJob(jobId);
|
|
200
|
+
if (!bullJob)
|
|
201
|
+
return undefined;
|
|
202
|
+
const state = await bullJob.getState();
|
|
203
|
+
const statusMap = {
|
|
204
|
+
completed: "completed",
|
|
205
|
+
failed: "failed",
|
|
206
|
+
delayed: "delayed",
|
|
207
|
+
active: "active",
|
|
208
|
+
waiting: "waiting",
|
|
209
|
+
"waiting-children": "waiting",
|
|
210
|
+
prioritized: "pending",
|
|
211
|
+
unknown: "pending",
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
id: bullJob.id ?? jobId,
|
|
215
|
+
queue,
|
|
216
|
+
status: statusMap[state] ?? "pending",
|
|
217
|
+
attempts: bullJob.attemptsMade,
|
|
218
|
+
maxRetries: bullJob.opts.attempts ?? 3,
|
|
219
|
+
...(bullJob.failedReason ? { failedReason: bullJob.failedReason } : {}),
|
|
220
|
+
createdAt: new Date(bullJob.timestamp).toISOString(),
|
|
221
|
+
...(bullJob.processedOn ? { processedAt: new Date(bullJob.processedOn).toISOString() } : {}),
|
|
222
|
+
...(bullJob.finishedOn ? { completedAt: new Date(bullJob.finishedOn).toISOString() } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
async getDeadLetteredJobs(queue) {
|
|
226
|
+
const entries = queue !== undefined ? [[queue, this.getOrCreateQueue(queue)]] : this.queues;
|
|
227
|
+
const deadLetters = [];
|
|
228
|
+
for (const [queueName, q] of entries) {
|
|
229
|
+
const failedJobs = await q.getFailed(0, -1);
|
|
230
|
+
for (const failedJob of failedJobs) {
|
|
231
|
+
const deadLetter = toBullMQDeadLetterJob(queueName, failedJob);
|
|
232
|
+
if (deadLetter)
|
|
233
|
+
deadLetters.push(deadLetter);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return deadLetters;
|
|
237
|
+
}
|
|
238
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
239
|
+
async close() {
|
|
240
|
+
const closeTasks = [];
|
|
241
|
+
for (const [name, worker] of this.workers) {
|
|
242
|
+
closeTasks.push(worker.close().then(() => {
|
|
243
|
+
logger.info("[queue:bullmq] Worker closed", { queue: name });
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
for (const [name, queue] of this.queues) {
|
|
247
|
+
closeTasks.push(queue.close().then(() => {
|
|
248
|
+
logger.info("[queue:bullmq] Queue closed", { queue: name });
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
await Promise.allSettled(closeTasks);
|
|
252
|
+
await this.connection.quit();
|
|
253
|
+
this.workers.clear();
|
|
254
|
+
this.queues.clear();
|
|
255
|
+
this.handlers.clear();
|
|
256
|
+
logger.info("[queue:bullmq] Provider closed");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DeadLetterJob, JobHandler, JobLifecycleActionResult, JobLifecycleEvent, JobPayload, JobResult, JobStatusInfo, QueueProvider } from "../types";
|
|
2
|
+
export declare class MemoryProvider implements QueueProvider {
|
|
3
|
+
readonly name: "memory";
|
|
4
|
+
private handlers;
|
|
5
|
+
private jobs;
|
|
6
|
+
private deadLetters;
|
|
7
|
+
private timers;
|
|
8
|
+
constructor();
|
|
9
|
+
enqueue(job: JobPayload): Promise<JobResult>;
|
|
10
|
+
enqueueBatch(jobs: JobPayload[]): Promise<JobResult[]>;
|
|
11
|
+
registerHandler<T extends Record<string, unknown>>(queue: string, type: string, handler: JobHandler<T>): void;
|
|
12
|
+
getJobStatus(jobId: string, _queue?: string): Promise<JobStatusInfo | undefined>;
|
|
13
|
+
getDeadLetteredJobs(queue?: string): Promise<DeadLetterJob[]>;
|
|
14
|
+
getJobLogs(jobId: string, _queue: string): Promise<JobLifecycleEvent[]>;
|
|
15
|
+
cancelJob(jobId: string, queue: string, reason?: string): Promise<JobLifecycleActionResult>;
|
|
16
|
+
retryJob(jobId: string, queue: string, reason?: string): Promise<JobLifecycleActionResult>;
|
|
17
|
+
private processJob;
|
|
18
|
+
private deadLetter;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private trackTimer;
|
|
21
|
+
private appendLog;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/providers/memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EACb,UAAU,EAEV,wBAAwB,EACxB,iBAAiB,EACjB,UAAU,EACV,SAAS,EACT,aAAa,EACb,aAAa,EACd,MAAM,UAAU,CAAC;AA4BlB,qBAAa,cAAe,YAAW,aAAa;IAClD,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAElC,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,IAAI,CAAqC;IACjD,OAAO,CAAC,WAAW,CAAyC;IAC5D,OAAO,CAAC,MAAM,CAAuC;;IAQ/C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;IA6B5C,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAU5D,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/C,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GACrB,IAAI;IAQD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC;IAoBhF,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAK7D,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAIvE,SAAS,CACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,MAAM,SAAa,GAClB,OAAO,CAAC,wBAAwB,CAAC;IAyB9B,QAAQ,CACZ,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,MAAM,SAAiB,GACtB,OAAO,CAAC,wBAAwB,CAAC;YA6BtB,UAAU;IAyDxB,OAAO,CAAC,UAAU;IA0BZ,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAW5B,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,SAAS;CAiBlB"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
export class MemoryProvider {
|
|
3
|
+
name = "memory";
|
|
4
|
+
handlers = new Map();
|
|
5
|
+
jobs = new Map();
|
|
6
|
+
deadLetters = new Map();
|
|
7
|
+
timers = [];
|
|
8
|
+
constructor() {
|
|
9
|
+
logger.info("[queue:memory] Provider initialised (dev/test only)");
|
|
10
|
+
}
|
|
11
|
+
// ── Enqueue ─────────────────────────────────────────────────────────────
|
|
12
|
+
async enqueue(job) {
|
|
13
|
+
const delaySec = job.options?.delaySec ?? 0;
|
|
14
|
+
this.jobs.set(job.id, {
|
|
15
|
+
payload: job,
|
|
16
|
+
status: delaySec > 0 ? "delayed" : "pending",
|
|
17
|
+
attempts: 0,
|
|
18
|
+
logs: [],
|
|
19
|
+
});
|
|
20
|
+
this.appendLog(job, "enqueued", {
|
|
21
|
+
...(delaySec > 0 ? { metadata: { delaySec } } : {}),
|
|
22
|
+
});
|
|
23
|
+
if (delaySec > 0) {
|
|
24
|
+
const timer = setTimeout(() => this.processJob(job), delaySec * 1000);
|
|
25
|
+
this.trackTimer(job.id, timer);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Process on next tick to simulate async behaviour
|
|
29
|
+
const timer = setTimeout(() => this.processJob(job), 0);
|
|
30
|
+
this.trackTimer(job.id, timer);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
jobId: job.id,
|
|
34
|
+
accepted: true,
|
|
35
|
+
provider: "memory",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async enqueueBatch(jobs) {
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const job of jobs) {
|
|
41
|
+
results.push(await this.enqueue(job));
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
// ── Handler Registration ────────────────────────────────────────────────
|
|
46
|
+
registerHandler(queue, type, handler) {
|
|
47
|
+
const key = `${queue}:${type}`;
|
|
48
|
+
this.handlers.set(key, handler);
|
|
49
|
+
logger.info("[queue:memory] Handler registered", { key });
|
|
50
|
+
}
|
|
51
|
+
// ── Job Status ──────────────────────────────────────────────────────────
|
|
52
|
+
async getJobStatus(jobId, _queue) {
|
|
53
|
+
const stored = this.jobs.get(jobId);
|
|
54
|
+
if (!stored)
|
|
55
|
+
return undefined;
|
|
56
|
+
return {
|
|
57
|
+
id: jobId,
|
|
58
|
+
queue: stored.payload.queue,
|
|
59
|
+
status: stored.status,
|
|
60
|
+
attempts: stored.attempts,
|
|
61
|
+
maxRetries: stored.payload.options?.maxRetries ?? 3,
|
|
62
|
+
...(stored.failedReason !== undefined ? { failedReason: stored.failedReason } : {}),
|
|
63
|
+
...(stored.canceledReason !== undefined ? { canceledReason: stored.canceledReason } : {}),
|
|
64
|
+
createdAt: stored.payload.createdAt,
|
|
65
|
+
...(stored.processedAt !== undefined ? { processedAt: stored.processedAt } : {}),
|
|
66
|
+
...(stored.completedAt !== undefined ? { completedAt: stored.completedAt } : {}),
|
|
67
|
+
...(stored.deadLetteredAt !== undefined ? { deadLetteredAt: stored.deadLetteredAt } : {}),
|
|
68
|
+
...(stored.canceledAt !== undefined ? { canceledAt: stored.canceledAt } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async getDeadLetteredJobs(queue) {
|
|
72
|
+
const jobs = [...this.deadLetters.values()];
|
|
73
|
+
return queue === undefined ? jobs : jobs.filter((job) => job.queue === queue);
|
|
74
|
+
}
|
|
75
|
+
async getJobLogs(jobId, _queue) {
|
|
76
|
+
return [...(this.jobs.get(jobId)?.logs ?? [])];
|
|
77
|
+
}
|
|
78
|
+
async cancelJob(jobId, queue, reason = "canceled") {
|
|
79
|
+
const stored = this.jobs.get(jobId);
|
|
80
|
+
if (!stored || stored.payload.queue !== queue) {
|
|
81
|
+
return { jobId, queue, accepted: false, action: "cancel", reason: "Job not found" };
|
|
82
|
+
}
|
|
83
|
+
if (!["pending", "delayed"].includes(stored.status)) {
|
|
84
|
+
return { jobId, queue, accepted: false, action: "cancel", reason: `Job is ${stored.status}` };
|
|
85
|
+
}
|
|
86
|
+
if (stored.timer) {
|
|
87
|
+
clearTimeout(stored.timer);
|
|
88
|
+
this.timers = this.timers.filter((timer) => timer !== stored.timer);
|
|
89
|
+
stored.timer = undefined;
|
|
90
|
+
}
|
|
91
|
+
const canceledAt = new Date().toISOString();
|
|
92
|
+
stored.status = "canceled";
|
|
93
|
+
stored.canceledReason = reason;
|
|
94
|
+
stored.canceledAt = canceledAt;
|
|
95
|
+
this.appendLog(stored.payload, "canceled", { reason });
|
|
96
|
+
return { jobId, queue, accepted: true, action: "cancel", reason };
|
|
97
|
+
}
|
|
98
|
+
async retryJob(jobId, queue, reason = "manual-retry") {
|
|
99
|
+
const stored = this.jobs.get(jobId);
|
|
100
|
+
if (!stored || stored.payload.queue !== queue) {
|
|
101
|
+
return { jobId, queue, accepted: false, action: "retry", reason: "Job not found" };
|
|
102
|
+
}
|
|
103
|
+
if (!["failed", "dead-lettered", "canceled"].includes(stored.status)) {
|
|
104
|
+
return { jobId, queue, accepted: false, action: "retry", reason: `Job is ${stored.status}` };
|
|
105
|
+
}
|
|
106
|
+
stored.status = "pending";
|
|
107
|
+
stored.attempts = 0;
|
|
108
|
+
stored.failedReason = undefined;
|
|
109
|
+
stored.canceledReason = undefined;
|
|
110
|
+
stored.processedAt = undefined;
|
|
111
|
+
stored.completedAt = undefined;
|
|
112
|
+
stored.deadLetteredAt = undefined;
|
|
113
|
+
stored.canceledAt = undefined;
|
|
114
|
+
this.deadLetters.delete(jobId);
|
|
115
|
+
this.appendLog(stored.payload, "retried", { reason });
|
|
116
|
+
const timer = setTimeout(() => this.processJob(stored.payload), 0);
|
|
117
|
+
this.trackTimer(jobId, timer);
|
|
118
|
+
return { jobId, queue, accepted: true, action: "retry", reason };
|
|
119
|
+
}
|
|
120
|
+
// ── Internal Processing ─────────────────────────────────────────────────
|
|
121
|
+
async processJob(job) {
|
|
122
|
+
const key = `${job.queue}:${job.type}`;
|
|
123
|
+
const handler = this.handlers.get(key);
|
|
124
|
+
const stored = this.jobs.get(job.id);
|
|
125
|
+
if (!stored)
|
|
126
|
+
return;
|
|
127
|
+
stored.timer = undefined;
|
|
128
|
+
if (stored.status === "canceled") {
|
|
129
|
+
logger.info("[queue:memory] Skipping canceled job", { jobId: job.id, key });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!handler) {
|
|
133
|
+
logger.warn("[queue:memory] No handler for job type", { key, jobId: job.id });
|
|
134
|
+
stored.status = "failed";
|
|
135
|
+
stored.failedReason = `No handler registered for ${key}`;
|
|
136
|
+
this.appendLog(job, "failed", { reason: stored.failedReason });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const maxRetries = job.options?.maxRetries ?? 3;
|
|
140
|
+
stored.status = "active";
|
|
141
|
+
stored.processedAt = new Date().toISOString();
|
|
142
|
+
this.appendLog(job, "started");
|
|
143
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
144
|
+
stored.attempts = attempt;
|
|
145
|
+
try {
|
|
146
|
+
await handler(job);
|
|
147
|
+
stored.status = "completed";
|
|
148
|
+
stored.completedAt = new Date().toISOString();
|
|
149
|
+
this.appendLog(job, "completed", { attempt });
|
|
150
|
+
logger.info("[queue:memory] Job completed", { jobId: job.id, key });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
155
|
+
this.appendLog(job, "attempt-failed", { attempt, reason: msg });
|
|
156
|
+
logger.warn("[queue:memory] Job attempt failed", {
|
|
157
|
+
jobId: job.id,
|
|
158
|
+
attempt,
|
|
159
|
+
maxRetries,
|
|
160
|
+
error: msg,
|
|
161
|
+
});
|
|
162
|
+
if (attempt === maxRetries) {
|
|
163
|
+
this.deadLetter(job, stored, msg, maxRetries);
|
|
164
|
+
logger.error("[queue:memory] Job exhausted retries", {
|
|
165
|
+
jobId: job.id,
|
|
166
|
+
key,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
deadLetter(job, stored, failedReason, maxRetries) {
|
|
173
|
+
const failedAt = new Date().toISOString();
|
|
174
|
+
stored.status = "dead-lettered";
|
|
175
|
+
stored.failedReason = failedReason;
|
|
176
|
+
stored.deadLetteredAt = failedAt;
|
|
177
|
+
this.appendLog(job, "dead-lettered", { reason: failedReason });
|
|
178
|
+
this.deadLetters.set(job.id, {
|
|
179
|
+
id: job.id,
|
|
180
|
+
queue: job.queue,
|
|
181
|
+
type: job.type,
|
|
182
|
+
originalJob: job,
|
|
183
|
+
attempts: stored.attempts,
|
|
184
|
+
maxRetries,
|
|
185
|
+
failedReason,
|
|
186
|
+
provider: "memory",
|
|
187
|
+
failedAt,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
191
|
+
async close() {
|
|
192
|
+
for (const timer of this.timers) {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
this.timers = [];
|
|
196
|
+
this.handlers.clear();
|
|
197
|
+
this.jobs.clear();
|
|
198
|
+
this.deadLetters.clear();
|
|
199
|
+
logger.info("[queue:memory] Provider closed");
|
|
200
|
+
}
|
|
201
|
+
trackTimer(jobId, timer) {
|
|
202
|
+
this.timers.push(timer);
|
|
203
|
+
const stored = this.jobs.get(jobId);
|
|
204
|
+
if (stored)
|
|
205
|
+
stored.timer = timer;
|
|
206
|
+
}
|
|
207
|
+
appendLog(job, action, details = {}) {
|
|
208
|
+
const stored = this.jobs.get(job.id);
|
|
209
|
+
if (!stored)
|
|
210
|
+
return;
|
|
211
|
+
stored.logs.push({
|
|
212
|
+
id: `log_${crypto.randomUUID()}`,
|
|
213
|
+
jobId: job.id,
|
|
214
|
+
queue: job.queue,
|
|
215
|
+
action,
|
|
216
|
+
at: new Date().toISOString(),
|
|
217
|
+
...details,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DeadLetterJob, JobHandler, JobPayload, JobResult, QStashProviderConfig, QueueDeadLetterRecord, QueueProvider } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Get a registered handler by its composite key (`queue:type`).
|
|
4
|
+
* Used by the QStash webhook verification middleware.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getQStashHandler(queue: string, type: string): JobHandler | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Get ALL registered handlers (for introspection / health checks).
|
|
9
|
+
*/
|
|
10
|
+
export declare function getQStashHandlerKeys(): string[];
|
|
11
|
+
export declare function toQStashDeadLetterJob(record: QueueDeadLetterRecord): DeadLetterJob | undefined;
|
|
12
|
+
export declare class QStashProvider implements QueueProvider {
|
|
13
|
+
readonly name: "qstash";
|
|
14
|
+
private client;
|
|
15
|
+
private callbackBaseUrl;
|
|
16
|
+
private token;
|
|
17
|
+
private dlqEndpoint;
|
|
18
|
+
private dlqFetcher;
|
|
19
|
+
constructor(config: Omit<QStashProviderConfig, "provider">);
|
|
20
|
+
enqueue(job: JobPayload): Promise<JobResult>;
|
|
21
|
+
enqueueBatch(jobs: JobPayload[]): Promise<JobResult[]>;
|
|
22
|
+
registerHandler<T extends Record<string, unknown>>(queue: string, type: string, handler: JobHandler<T>): void;
|
|
23
|
+
getDeadLetteredJobs(queue?: string): Promise<DeadLetterJob[]>;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=qstash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"qstash.d.ts","sourceRoot":"","sources":["../../src/providers/qstash.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,aAAa,EACb,UAAU,EACV,UAAU,EACV,SAAS,EACT,oBAAoB,EAEpB,qBAAqB,EACrB,aAAa,EACd,MAAM,UAAU,CAAC;AAoBlB;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAEpF;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,EAAE,CAE/C;AAiDD,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,aAAa,GAAG,SAAS,CAmB9F;AAED,qBAAa,cAAe,YAAW,aAAa;IAClD,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAU;IAElC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,UAAU,CAAqC;gBAE3C,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,UAAU,CAAC;IAqBpD,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC;IA2C5C,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IA4C5D,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/C,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GACrB,IAAI;IAMD,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IA2B7D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
|