@iskra-bun/worker-kit 0.1.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/CHANGELOG.md +7 -0
- package/README.md +31 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +147 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/errors.ts +19 -0
- package/src/index.ts +148 -0
- package/src/types.ts +34 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @iskra-bun/worker-kit
|
|
2
|
+
|
|
3
|
+
Cola de jobs en segundo plano para Iskra basada en [BullMQ](https://docs.bullmq.io). Maneja handlers, reintentos, backoff y concurrencia.
|
|
4
|
+
|
|
5
|
+
## Instalacion
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @iskra-bun/worker-kit @iskra-bun/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requiere un Redis accesible (BullMQ).
|
|
12
|
+
|
|
13
|
+
## Uso rapido
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { App } from '@iskra-bun/core'
|
|
17
|
+
import { WorkerManager } from '@iskra-bun/worker-kit'
|
|
18
|
+
|
|
19
|
+
const app = new App({ name: 'mi-worker' })
|
|
20
|
+
app.register(new WorkerManager({ queue: 'mis-jobs', concurrency: 2 }))
|
|
21
|
+
|
|
22
|
+
await app.start()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Documentacion
|
|
26
|
+
|
|
27
|
+
Guia completa: [docs/worker-kit.md](../../docs/worker-kit.md)
|
|
28
|
+
|
|
29
|
+
## Licencia
|
|
30
|
+
|
|
31
|
+
AGPL-3.0-or-later
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { IskraError, Driver, App } from '@iskra-bun/core';
|
|
2
|
+
|
|
3
|
+
interface WorkerManagerOptions {
|
|
4
|
+
/** URL de conexión a Redis (ej: 'redis://localhost:6379') */
|
|
5
|
+
connection: string | {
|
|
6
|
+
host: string;
|
|
7
|
+
port: number;
|
|
8
|
+
password?: string;
|
|
9
|
+
db?: number;
|
|
10
|
+
};
|
|
11
|
+
/** Cantidad de jobs que se procesan en paralelo (default: 1) */
|
|
12
|
+
concurrency?: number;
|
|
13
|
+
/** Nombre de la queue en Redis (default: 'iskra-jobs') */
|
|
14
|
+
queueName?: string;
|
|
15
|
+
/** Opciones por defecto para cada job */
|
|
16
|
+
defaultJobOptions?: JobOptions;
|
|
17
|
+
}
|
|
18
|
+
interface JobOptions {
|
|
19
|
+
/** Reintentos en caso de fallo */
|
|
20
|
+
attempts?: number;
|
|
21
|
+
/** Delay antes de procesar el job (ms) */
|
|
22
|
+
delay?: number;
|
|
23
|
+
/** Prioridad (menor = mayor prioridad) */
|
|
24
|
+
priority?: number;
|
|
25
|
+
/** Estrategia de backoff entre reintentos */
|
|
26
|
+
backoff?: {
|
|
27
|
+
type: 'fixed' | 'exponential';
|
|
28
|
+
delay: number;
|
|
29
|
+
};
|
|
30
|
+
/** Eliminar el job de Redis al completarse */
|
|
31
|
+
removeOnComplete?: boolean | number;
|
|
32
|
+
/** Eliminar el job de Redis al fallar */
|
|
33
|
+
removeOnFail?: boolean | number;
|
|
34
|
+
}
|
|
35
|
+
type JobHandler = (job: {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
data: any;
|
|
39
|
+
attemptsMade: number;
|
|
40
|
+
}) => Promise<void>;
|
|
41
|
+
|
|
42
|
+
declare class QueueError extends IskraError {
|
|
43
|
+
constructor(message: string, options?: {
|
|
44
|
+
cause?: Error;
|
|
45
|
+
context?: Record<string, unknown>;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
declare class JobError extends IskraError {
|
|
49
|
+
constructor(message: string, options?: {
|
|
50
|
+
cause?: Error;
|
|
51
|
+
context?: Record<string, unknown>;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declare class WorkerManager implements Driver {
|
|
56
|
+
name: string;
|
|
57
|
+
private app;
|
|
58
|
+
private handlers;
|
|
59
|
+
private queue;
|
|
60
|
+
private worker;
|
|
61
|
+
private options;
|
|
62
|
+
constructor(options: WorkerManagerOptions);
|
|
63
|
+
init(app: App): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Registra un handler para un tipo de job.
|
|
66
|
+
*/
|
|
67
|
+
register(jobName: string, handler: JobHandler): this;
|
|
68
|
+
/**
|
|
69
|
+
* Encola un job para ser procesado.
|
|
70
|
+
*/
|
|
71
|
+
enqueue(name: string, data: any, opts?: JobOptions): Promise<{
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
data: any;
|
|
75
|
+
}>;
|
|
76
|
+
start(): Promise<void>;
|
|
77
|
+
stop(): Promise<void>;
|
|
78
|
+
private parseConnection;
|
|
79
|
+
private mapJobOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { JobError, type JobHandler, type JobOptions, QueueError, WorkerManager, type WorkerManagerOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Queue, Worker } from "bullmq";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
import { IskraError, ErrorCodes } from "@iskra-bun/core";
|
|
6
|
+
var QueueError = class extends IskraError {
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, { code: ErrorCodes.QUEUE_ERROR, ...options });
|
|
9
|
+
this.name = "QueueError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var JobError = class extends IskraError {
|
|
13
|
+
constructor(message, options) {
|
|
14
|
+
super(message, { code: ErrorCodes.JOB_ERROR, ...options });
|
|
15
|
+
this.name = "JobError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// src/index.ts
|
|
20
|
+
var WorkerManager = class {
|
|
21
|
+
name = "WorkerManager";
|
|
22
|
+
app = null;
|
|
23
|
+
handlers = /* @__PURE__ */ new Map();
|
|
24
|
+
queue = null;
|
|
25
|
+
worker = null;
|
|
26
|
+
options;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
}
|
|
30
|
+
async init(app) {
|
|
31
|
+
this.app = app;
|
|
32
|
+
const connection = this.parseConnection();
|
|
33
|
+
try {
|
|
34
|
+
this.queue = new Queue(this.options.queueName || "iskra-jobs", {
|
|
35
|
+
connection,
|
|
36
|
+
defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions)
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new QueueError("Failed to initialize BullMQ queue", {
|
|
40
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
41
|
+
context: { queueName: this.options.queueName || "iskra-jobs" }
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Registra un handler para un tipo de job.
|
|
47
|
+
*/
|
|
48
|
+
register(jobName, handler) {
|
|
49
|
+
this.handlers.set(jobName, handler);
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Encola un job para ser procesado.
|
|
54
|
+
*/
|
|
55
|
+
async enqueue(name, data, opts) {
|
|
56
|
+
if (!this.queue) {
|
|
57
|
+
throw new QueueError("Queue not initialized. Did you call init()?", {
|
|
58
|
+
context: { jobName: name }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const job = await this.queue.add(name, data, this.mapJobOptions(opts));
|
|
62
|
+
this.app?.logger.debug({ jobId: job.id, jobName: name }, "Job enqueued");
|
|
63
|
+
return { id: job.id, name, data };
|
|
64
|
+
}
|
|
65
|
+
async start() {
|
|
66
|
+
const connection = this.parseConnection();
|
|
67
|
+
this.worker = new Worker(
|
|
68
|
+
this.options.queueName || "iskra-jobs",
|
|
69
|
+
async (job) => {
|
|
70
|
+
const handler = this.handlers.get(job.name);
|
|
71
|
+
if (!handler) {
|
|
72
|
+
this.app?.logger.warn({ jobName: job.name, jobId: job.id }, "No handler registered for job");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await handler({
|
|
77
|
+
id: job.id,
|
|
78
|
+
name: job.name,
|
|
79
|
+
data: job.data,
|
|
80
|
+
attemptsMade: job.attemptsMade
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const jobErr = new JobError(`Job "${job.name}" failed`, {
|
|
84
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
85
|
+
context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade }
|
|
86
|
+
});
|
|
87
|
+
this.app?.logger.error({ err: jobErr }, jobErr.message);
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
connection,
|
|
93
|
+
concurrency: this.options.concurrency || 1
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
this.worker.on("completed", (job) => {
|
|
97
|
+
this.app?.logger.debug({ jobId: job.id, jobName: job.name }, "Job completed");
|
|
98
|
+
});
|
|
99
|
+
this.worker.on("failed", (job, err) => {
|
|
100
|
+
this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, "Job failed");
|
|
101
|
+
});
|
|
102
|
+
this.app?.logger.info({
|
|
103
|
+
queue: this.options.queueName || "iskra-jobs",
|
|
104
|
+
concurrency: this.options.concurrency || 1
|
|
105
|
+
}, "WorkerManager started");
|
|
106
|
+
}
|
|
107
|
+
async stop() {
|
|
108
|
+
const closePromises = [];
|
|
109
|
+
if (this.worker) {
|
|
110
|
+
closePromises.push(this.worker.close());
|
|
111
|
+
}
|
|
112
|
+
if (this.queue) {
|
|
113
|
+
closePromises.push(this.queue.close());
|
|
114
|
+
}
|
|
115
|
+
await Promise.all(closePromises);
|
|
116
|
+
this.app?.logger.info("WorkerManager stopped");
|
|
117
|
+
}
|
|
118
|
+
parseConnection() {
|
|
119
|
+
if (typeof this.options.connection === "string") {
|
|
120
|
+
const url = new URL(this.options.connection);
|
|
121
|
+
return {
|
|
122
|
+
host: url.hostname,
|
|
123
|
+
port: Number(url.port) || 6379,
|
|
124
|
+
password: url.password || void 0,
|
|
125
|
+
db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return this.options.connection;
|
|
129
|
+
}
|
|
130
|
+
mapJobOptions(opts) {
|
|
131
|
+
if (!opts) return void 0;
|
|
132
|
+
return {
|
|
133
|
+
attempts: opts.attempts,
|
|
134
|
+
delay: opts.delay,
|
|
135
|
+
priority: opts.priority,
|
|
136
|
+
backoff: opts.backoff,
|
|
137
|
+
removeOnComplete: opts.removeOnComplete,
|
|
138
|
+
removeOnFail: opts.removeOnFail
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
export {
|
|
143
|
+
JobError,
|
|
144
|
+
QueueError,
|
|
145
|
+
WorkerManager
|
|
146
|
+
};
|
|
147
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/errors.ts"],"sourcesContent":["import type { Driver, App } from '@iskra-bun/core';\nimport { Queue, Worker, type Job as BullJob } from 'bullmq';\nimport { QueueError, JobError } from './errors';\nimport type { WorkerManagerOptions, JobOptions, JobHandler } from './types';\n\nexport type { WorkerManagerOptions, JobOptions, JobHandler } from './types';\nexport * from './errors';\n\nexport class WorkerManager implements Driver {\n name = 'WorkerManager';\n private app: App | null = null;\n private handlers: Map<string, JobHandler> = new Map();\n private queue: Queue | null = null;\n private worker: Worker | null = null;\n private options: WorkerManagerOptions;\n\n constructor(options: WorkerManagerOptions) {\n this.options = options;\n }\n\n async init(app: App) {\n this.app = app;\n\n const connection = this.parseConnection();\n\n try {\n this.queue = new Queue(this.options.queueName || 'iskra-jobs', {\n connection,\n defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),\n });\n } catch (err) {\n throw new QueueError('Failed to initialize BullMQ queue', {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { queueName: this.options.queueName || 'iskra-jobs' },\n });\n }\n }\n\n /**\n * Registra un handler para un tipo de job.\n */\n register(jobName: string, handler: JobHandler) {\n this.handlers.set(jobName, handler);\n return this;\n }\n\n /**\n * Encola un job para ser procesado.\n */\n async enqueue(name: string, data: any, opts?: JobOptions) {\n if (!this.queue) {\n throw new QueueError('Queue not initialized. Did you call init()?', {\n context: { jobName: name },\n });\n }\n\n const job = await this.queue.add(name, data, this.mapJobOptions(opts));\n this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');\n return { id: job.id!, name, data };\n }\n\n async start() {\n const connection = this.parseConnection();\n\n this.worker = new Worker(\n this.options.queueName || 'iskra-jobs',\n async (job: BullJob) => {\n const handler = this.handlers.get(job.name);\n if (!handler) {\n this.app?.logger.warn({ jobName: job.name, jobId: job.id }, 'No handler registered for job');\n return;\n }\n\n try {\n await handler({\n id: job.id!,\n name: job.name,\n data: job.data,\n attemptsMade: job.attemptsMade,\n });\n } catch (err) {\n const jobErr = new JobError(`Job \"${job.name}\" failed`, {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },\n });\n this.app?.logger.error({ err: jobErr }, jobErr.message);\n throw err; // Re-throw para que BullMQ maneje el retry\n }\n },\n {\n connection,\n concurrency: this.options.concurrency || 1,\n },\n );\n\n this.worker.on('completed', (job) => {\n this.app?.logger.debug({ jobId: job.id, jobName: job.name }, 'Job completed');\n });\n\n this.worker.on('failed', (job, err) => {\n this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');\n });\n\n this.app?.logger.info({\n queue: this.options.queueName || 'iskra-jobs',\n concurrency: this.options.concurrency || 1,\n }, 'WorkerManager started');\n }\n\n async stop() {\n const closePromises: Promise<void>[] = [];\n\n if (this.worker) {\n closePromises.push(this.worker.close());\n }\n if (this.queue) {\n closePromises.push(this.queue.close());\n }\n\n await Promise.all(closePromises);\n this.app?.logger.info('WorkerManager stopped');\n }\n\n private parseConnection() {\n if (typeof this.options.connection === 'string') {\n const url = new URL(this.options.connection);\n return {\n host: url.hostname,\n port: Number(url.port) || 6379,\n password: url.password || undefined,\n db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,\n };\n }\n return this.options.connection;\n }\n\n private mapJobOptions(opts?: JobOptions) {\n if (!opts) return undefined;\n return {\n attempts: opts.attempts,\n delay: opts.delay,\n priority: opts.priority,\n backoff: opts.backoff,\n removeOnComplete: opts.removeOnComplete,\n removeOnFail: opts.removeOnFail,\n };\n }\n}\n","import { IskraError, ErrorCodes } from '@iskra-bun/core';\n\n// ─── Queue Error ─────────────────────────────────────────────────────────────\n\nexport class QueueError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.QUEUE_ERROR, ...options });\n this.name = 'QueueError';\n }\n}\n\n// ─── Job Error ───────────────────────────────────────────────────────────────\n\nexport class JobError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.JOB_ERROR, ...options });\n this.name = 'JobError';\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iskra-bun/worker-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cola de jobs en segundo plano para Iskra con BullMQ.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"iskra",
|
|
7
|
+
"bun",
|
|
8
|
+
"typescript",
|
|
9
|
+
"bullmq",
|
|
10
|
+
"jobs",
|
|
11
|
+
"queue",
|
|
12
|
+
"worker"
|
|
13
|
+
],
|
|
14
|
+
"author": "Joan Lascano",
|
|
15
|
+
"license": "AGPL-3.0-or-later",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/fearful/iskra.git",
|
|
19
|
+
"directory": "packages/worker-kit"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/fearful/iskra/tree/main/packages/worker-kit#readme",
|
|
22
|
+
"bugs": "https://github.com/fearful/iskra/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"source": "./src/index.ts",
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"build": "tsup --config ../../tsup.config.ts"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@iskra-bun/core": "0.1.0",
|
|
51
|
+
"bullmq": "^5.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.10.2"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { IskraError, ErrorCodes } from '@iskra-bun/core';
|
|
2
|
+
|
|
3
|
+
// ─── Queue Error ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export class QueueError extends IskraError {
|
|
6
|
+
constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {
|
|
7
|
+
super(message, { code: ErrorCodes.QUEUE_ERROR, ...options });
|
|
8
|
+
this.name = 'QueueError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Job Error ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export class JobError extends IskraError {
|
|
15
|
+
constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {
|
|
16
|
+
super(message, { code: ErrorCodes.JOB_ERROR, ...options });
|
|
17
|
+
this.name = 'JobError';
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Driver, App } from '@iskra-bun/core';
|
|
2
|
+
import { Queue, Worker, type Job as BullJob } from 'bullmq';
|
|
3
|
+
import { QueueError, JobError } from './errors';
|
|
4
|
+
import type { WorkerManagerOptions, JobOptions, JobHandler } from './types';
|
|
5
|
+
|
|
6
|
+
export type { WorkerManagerOptions, JobOptions, JobHandler } from './types';
|
|
7
|
+
export * from './errors';
|
|
8
|
+
|
|
9
|
+
export class WorkerManager implements Driver {
|
|
10
|
+
name = 'WorkerManager';
|
|
11
|
+
private app: App | null = null;
|
|
12
|
+
private handlers: Map<string, JobHandler> = new Map();
|
|
13
|
+
private queue: Queue | null = null;
|
|
14
|
+
private worker: Worker | null = null;
|
|
15
|
+
private options: WorkerManagerOptions;
|
|
16
|
+
|
|
17
|
+
constructor(options: WorkerManagerOptions) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async init(app: App) {
|
|
22
|
+
this.app = app;
|
|
23
|
+
|
|
24
|
+
const connection = this.parseConnection();
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
this.queue = new Queue(this.options.queueName || 'iskra-jobs', {
|
|
28
|
+
connection,
|
|
29
|
+
defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),
|
|
30
|
+
});
|
|
31
|
+
} catch (err) {
|
|
32
|
+
throw new QueueError('Failed to initialize BullMQ queue', {
|
|
33
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
34
|
+
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Registra un handler para un tipo de job.
|
|
41
|
+
*/
|
|
42
|
+
register(jobName: string, handler: JobHandler) {
|
|
43
|
+
this.handlers.set(jobName, handler);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encola un job para ser procesado.
|
|
49
|
+
*/
|
|
50
|
+
async enqueue(name: string, data: any, opts?: JobOptions) {
|
|
51
|
+
if (!this.queue) {
|
|
52
|
+
throw new QueueError('Queue not initialized. Did you call init()?', {
|
|
53
|
+
context: { jobName: name },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const job = await this.queue.add(name, data, this.mapJobOptions(opts));
|
|
58
|
+
this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');
|
|
59
|
+
return { id: job.id!, name, data };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start() {
|
|
63
|
+
const connection = this.parseConnection();
|
|
64
|
+
|
|
65
|
+
this.worker = new Worker(
|
|
66
|
+
this.options.queueName || 'iskra-jobs',
|
|
67
|
+
async (job: BullJob) => {
|
|
68
|
+
const handler = this.handlers.get(job.name);
|
|
69
|
+
if (!handler) {
|
|
70
|
+
this.app?.logger.warn({ jobName: job.name, jobId: job.id }, 'No handler registered for job');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await handler({
|
|
76
|
+
id: job.id!,
|
|
77
|
+
name: job.name,
|
|
78
|
+
data: job.data,
|
|
79
|
+
attemptsMade: job.attemptsMade,
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const jobErr = new JobError(`Job "${job.name}" failed`, {
|
|
83
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
84
|
+
context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
|
|
85
|
+
});
|
|
86
|
+
this.app?.logger.error({ err: jobErr }, jobErr.message);
|
|
87
|
+
throw err; // Re-throw para que BullMQ maneje el retry
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
connection,
|
|
92
|
+
concurrency: this.options.concurrency || 1,
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
this.worker.on('completed', (job) => {
|
|
97
|
+
this.app?.logger.debug({ jobId: job.id, jobName: job.name }, 'Job completed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.worker.on('failed', (job, err) => {
|
|
101
|
+
this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.app?.logger.info({
|
|
105
|
+
queue: this.options.queueName || 'iskra-jobs',
|
|
106
|
+
concurrency: this.options.concurrency || 1,
|
|
107
|
+
}, 'WorkerManager started');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async stop() {
|
|
111
|
+
const closePromises: Promise<void>[] = [];
|
|
112
|
+
|
|
113
|
+
if (this.worker) {
|
|
114
|
+
closePromises.push(this.worker.close());
|
|
115
|
+
}
|
|
116
|
+
if (this.queue) {
|
|
117
|
+
closePromises.push(this.queue.close());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await Promise.all(closePromises);
|
|
121
|
+
this.app?.logger.info('WorkerManager stopped');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private parseConnection() {
|
|
125
|
+
if (typeof this.options.connection === 'string') {
|
|
126
|
+
const url = new URL(this.options.connection);
|
|
127
|
+
return {
|
|
128
|
+
host: url.hostname,
|
|
129
|
+
port: Number(url.port) || 6379,
|
|
130
|
+
password: url.password || undefined,
|
|
131
|
+
db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return this.options.connection;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private mapJobOptions(opts?: JobOptions) {
|
|
138
|
+
if (!opts) return undefined;
|
|
139
|
+
return {
|
|
140
|
+
attempts: opts.attempts,
|
|
141
|
+
delay: opts.delay,
|
|
142
|
+
priority: opts.priority,
|
|
143
|
+
backoff: opts.backoff,
|
|
144
|
+
removeOnComplete: opts.removeOnComplete,
|
|
145
|
+
removeOnFail: opts.removeOnFail,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface WorkerManagerOptions {
|
|
2
|
+
/** URL de conexión a Redis (ej: 'redis://localhost:6379') */
|
|
3
|
+
connection: string | { host: string; port: number; password?: string; db?: number };
|
|
4
|
+
/** Cantidad de jobs que se procesan en paralelo (default: 1) */
|
|
5
|
+
concurrency?: number;
|
|
6
|
+
/** Nombre de la queue en Redis (default: 'iskra-jobs') */
|
|
7
|
+
queueName?: string;
|
|
8
|
+
/** Opciones por defecto para cada job */
|
|
9
|
+
defaultJobOptions?: JobOptions;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface JobOptions {
|
|
13
|
+
/** Reintentos en caso de fallo */
|
|
14
|
+
attempts?: number;
|
|
15
|
+
/** Delay antes de procesar el job (ms) */
|
|
16
|
+
delay?: number;
|
|
17
|
+
/** Prioridad (menor = mayor prioridad) */
|
|
18
|
+
priority?: number;
|
|
19
|
+
/** Estrategia de backoff entre reintentos */
|
|
20
|
+
backoff?: {
|
|
21
|
+
type: 'fixed' | 'exponential';
|
|
22
|
+
delay: number;
|
|
23
|
+
};
|
|
24
|
+
/** Eliminar el job de Redis al completarse */
|
|
25
|
+
removeOnComplete?: boolean | number;
|
|
26
|
+
/** Eliminar el job de Redis al fallar */
|
|
27
|
+
removeOnFail?: boolean | number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface JobData {
|
|
31
|
+
[key: string]: any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type JobHandler = (job: { id: string; name: string; data: any; attemptsMade: number }) => Promise<void>;
|