Skip to content

Commit 5927524

Browse files
committed
feat: queue plugin
1 parent a0533e9 commit 5927524

7 files changed

Lines changed: 609 additions & 17 deletions

File tree

src/core/queue.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventEmitter } from 'events';
22
import type { JobStatus, JobMeta, QueueMessage, QueueEvent, JobData, JobOptions, BaseJobRequest } from '../interfaces/job.ts';
3+
import type { QueuePlugin, QueueOptions } from '../interfaces/plugin.ts';
34

45
/**
56
* Abstract queue class providing event-based job processing.
@@ -29,16 +30,23 @@ import type { JobStatus, JobMeta, QueueMessage, QueueEvent, JobData, JobOptions,
2930
*/
3031
export abstract class Queue<TJobMap = Record<string, any>, TJobRequest extends BaseJobRequest<any> = BaseJobRequest<any>> extends EventEmitter {
3132
protected ttrDefault = 300;
33+
protected plugins: QueuePlugin[];
34+
protected pluginDisposers: Array<() => Promise<void>> = [];
35+
public readonly name?: string;
3236

3337
/**
3438
* Creates a new Queue instance.
3539
*
3640
* @param options - Configuration options
3741
* @param options.ttrDefault - Default time-to-run for jobs in seconds (default: 300)
42+
* @param options.name - Optional name for the queue
43+
* @param options.plugins - Array of plugins to use with this queue
3844
*/
39-
constructor(options: { ttrDefault?: number } = {}) {
45+
constructor(options: QueueOptions = {}) {
4046
super();
4147
if (options.ttrDefault) this.ttrDefault = options.ttrDefault;
48+
this.name = options.name;
49+
this.plugins = options.plugins || [];
4250
}
4351

4452

@@ -147,22 +155,111 @@ export abstract class Queue<TJobMap = Record<string, any>, TJobRequest extends B
147155
* ```
148156
*/
149157
async run(repeat: boolean = false, timeout: number = 0): Promise<void> {
150-
const canContinue = () => true;
158+
const disposers = [...this.pluginDisposers];
151159

152-
while (canContinue()) {
153-
const message = await this.reserve(timeout);
154-
155-
if (!message) {
156-
if (!repeat) break;
157-
if (timeout > 0) {
158-
await this.sleep(timeout * 1000);
160+
// 1. Initialize plugins if not already initialized
161+
if (this.pluginDisposers.length === 0) {
162+
for (const plugin of this.plugins) {
163+
if (plugin.init) {
164+
const dispose = await plugin.init({ queue: this as any, queueName: this.name });
165+
if (dispose) {
166+
this.pluginDisposers.push(dispose);
167+
disposers.push(dispose);
168+
}
159169
}
160-
continue;
161170
}
171+
}
172+
173+
try {
174+
// 2. Main processing loop (enhancing existing loop)
175+
let stopped = false;
176+
177+
while (!stopped) {
178+
// Check if any plugin wants to stop
179+
try {
180+
for (const plugin of this.plugins) {
181+
if (plugin.beforePoll) {
182+
const result = await plugin.beforePoll();
183+
if (result === 'stop') {
184+
stopped = true;
185+
break;
186+
}
187+
}
188+
}
189+
} catch (error) {
190+
console.error('Plugin beforePoll error:', error);
191+
// Continue polling despite plugin error
192+
}
193+
if (stopped) break;
194+
195+
const message = await this.reserve(timeout);
196+
if (!message) {
197+
if (!repeat) break;
198+
if (timeout > 0) {
199+
await this.sleep(timeout * 1000);
200+
}
201+
continue;
202+
}
162203

163-
const success = await this.handleMessage(message);
164-
if (success) {
165-
await this.release(message);
204+
// 3. Pre-execution hooks
205+
try {
206+
for (const plugin of this.plugins) {
207+
if (plugin.beforeJob) {
208+
await plugin.beforeJob(message);
209+
}
210+
}
211+
} catch (error) {
212+
console.error('Plugin beforeJob error:', error);
213+
// Continue processing despite plugin error
214+
}
215+
216+
// 4. Execute job (with plugin hooks)
217+
let success = false;
218+
let jobError: unknown;
219+
220+
// We need to track errors for plugins, but handleMessage catches them internally
221+
// So we'll set up an event listener to capture the error
222+
let capturedError: unknown;
223+
const errorListener = (event: QueueEvent) => {
224+
if (event.type === 'afterError' && event.id === message.id) {
225+
capturedError = event.error;
226+
}
227+
};
228+
229+
this.once('afterError', errorListener);
230+
231+
try {
232+
success = await this.handleMessage(message);
233+
jobError = capturedError; // Will be undefined if no error
234+
} catch (error) {
235+
// This shouldn't happen since handleMessage catches errors
236+
jobError = error;
237+
success = false;
238+
} finally {
239+
this.removeListener('afterError', errorListener);
240+
}
241+
242+
// 5. Post-execution hooks
243+
try {
244+
for (const plugin of this.plugins) {
245+
if (plugin.afterJob) {
246+
await plugin.afterJob(message, jobError);
247+
}
248+
}
249+
} catch (error) {
250+
console.error('Plugin afterJob error:', error);
251+
// Don't let plugin errors affect job completion
252+
}
253+
254+
// Complete the job if successful
255+
if (success) {
256+
await this.release(message);
257+
}
258+
}
259+
} finally {
260+
// 6. Cleanup only if we initialized in this run
261+
for (const dispose of disposers.reverse()) {
262+
await dispose();
166263
}
167264
}
168265
}
@@ -176,6 +273,7 @@ export abstract class Queue<TJobMap = Record<string, any>, TJobRequest extends B
176273
*/
177274
protected async handleMessage(message: QueueMessage): Promise<boolean> {
178275
try {
276+
// Parse the job data (this may have been modified by plugins)
179277
const jobData: JobData = JSON.parse(message.payload);
180278
const { name, payload } = jobData;
181279

src/drivers/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Queue } from '../core/queue.ts';
22
import type { JobStatus, JobMeta, QueueMessage, DbJobRequest } from '../interfaces/job.ts';
33
import type { DatabaseAdapter, QueueJobRecord } from '../interfaces/database.ts';
4+
import type { QueueOptions } from '../interfaces/plugin.ts';
45

56
export class DbQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, DbJobRequest<any>> {
67
constructor(
78
private db: DatabaseAdapter,
8-
options: { ttrDefault?: number } = {}
9+
options: QueueOptions = {}
910
) {
1011
super(options);
1112
}

src/drivers/file.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { open } from 'fs/promises';
33
import path from 'path';
44
import { Queue } from '../core/queue.js';
55
import type { QueueMessage, JobMeta, JobStatus, FileJobRequest } from '../interfaces/job.js';
6+
import type { QueueOptions } from '../interfaces/plugin.js';
67

78
interface IndexData {
89
lastId: number;
@@ -17,7 +18,7 @@ interface ReservedInfo {
1718
attempt: number;
1819
}
1920

20-
interface FileQueueOptions {
21+
interface FileQueueOptions extends QueueOptions {
2122
path: string;
2223
dirMode?: number;
2324
fileMode?: number;
@@ -30,7 +31,7 @@ export class FileQueue<TJobMap = Record<string, any>> extends Queue<TJobMap, Fil
3031
private indexPath: string;
3132

3233
constructor(options: FileQueueOptions) {
33-
super();
34+
super(options);
3435
this.path = path.resolve(options.path);
3536
this.dirMode = options.dirMode ?? 0o755;
3637
this.fileMode = options.fileMode;

src/drivers/sqs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
QueueMessage,
1717
SqsJobRequest,
1818
} from "../interfaces/job.ts";
19+
import type { QueueOptions } from "../interfaces/plugin.ts";
1920

2021
export class SqsQueue<TJobMap = Record<string, any>> extends Queue<
2122
TJobMap,
@@ -24,7 +25,7 @@ export class SqsQueue<TJobMap = Record<string, any>> extends Queue<
2425
constructor(
2526
private client: SQSClient,
2627
private queueUrl: string,
27-
options: { ttrDefault?: number } = {}
28+
options: QueueOptions = {}
2829
) {
2930
super(options);
3031
}

src/interfaces/plugin.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Queue } from '../core/queue.ts';
2+
import type { QueueMessage } from './job.ts';
3+
4+
/**
5+
* Plugin interface for extending queue functionality.
6+
*
7+
* Plugins can hook into the queue lifecycle to add features like:
8+
* - Task protection (ECS, Kubernetes)
9+
* - Metrics collection
10+
* - Distributed tracing
11+
* - Circuit breaking
12+
* - Job enrichment
13+
*/
14+
export interface QueuePlugin {
15+
/**
16+
* Called once when the queue starts processing.
17+
* Use this hook to initialize resources, connections, or state.
18+
* Return a cleanup function that will be called on shutdown.
19+
*
20+
* @param ctx - Context containing the queue instance and optional name
21+
* @returns Optional cleanup function
22+
*/
23+
init?(ctx: { queue: Queue; queueName?: string }): Promise<(() => Promise<void>) | void>;
24+
25+
/**
26+
* Called before each poll/reserve attempt.
27+
* Use this hook to control whether the queue should continue polling for jobs.
28+
*
29+
* Examples:
30+
* - Check if environment is being drained (ECS, Kubernetes)
31+
* - Implement circuit breaker logic
32+
* - Check resource availability
33+
*
34+
* @returns 'stop' to gracefully shut down processing, 'continue' or void to continue
35+
*/
36+
beforePoll?(): Promise<'continue' | 'stop' | void>;
37+
38+
/**
39+
* Called after a job is reserved but before execution.
40+
* Use this hook to prepare for job processing using job details.
41+
*
42+
* Examples:
43+
* - Acquire task protection using job TTR
44+
* - Start distributed tracing spans
45+
* - Enrich job with metadata
46+
* - Track job start metrics
47+
*
48+
* Note: Once a job is reserved, it will be processed. This hook cannot reject jobs.
49+
* Use beforePoll() if you need to stop processing entirely.
50+
*
51+
* @param job - The reserved job message with full context
52+
*/
53+
beforeJob?(job: QueueMessage): Promise<void>;
54+
55+
/**
56+
* Called after job execution (success or failure).
57+
* Use this hook for cleanup and post-processing.
58+
*
59+
* Examples:
60+
* - Release task protection
61+
* - End distributed tracing spans
62+
* - Record completion metrics
63+
* - Clean up resources
64+
*
65+
* @param job - The job that was processed
66+
* @param error - Error if job failed, undefined if successful
67+
*/
68+
afterJob?(job: QueueMessage, error?: unknown): Promise<void>;
69+
}
70+
71+
/**
72+
* Extended queue options interface that includes plugin support.
73+
*/
74+
export interface QueueOptions {
75+
/**
76+
* Default time-to-run for jobs in seconds.
77+
*/
78+
ttrDefault?: number;
79+
80+
/**
81+
* Optional name for the queue (used in plugin context).
82+
*/
83+
name?: string;
84+
85+
/**
86+
* Array of plugins to use with this queue.
87+
* Plugins are initialized when the queue starts processing
88+
* and their hooks are called during the job lifecycle.
89+
*/
90+
plugins?: QueuePlugin[];
91+
}

0 commit comments

Comments
 (0)