feat: add reusable jobqueue library
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
135
tests/JobQueue.test.ts
Normal file
135
tests/JobQueue.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { JobQueue } from '../src/index.js';
|
||||
import { cleanupDir, createDbPath, createTempDir, waitFor } from './helpers.js';
|
||||
|
||||
describe('JobQueue', () => {
|
||||
it('runs multi-phase jobs to completion', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['download', 'process'],
|
||||
concurrency: 1,
|
||||
});
|
||||
const events: string[] = [];
|
||||
|
||||
queue.on('job:started', () => events.push('started'));
|
||||
queue.on('job:phase:completed', (_, phase) => events.push(`phase:${phase.name}`));
|
||||
queue.on('job:completed', () => events.push('completed'));
|
||||
|
||||
queue.handle('download', async (_job, ctx) => {
|
||||
await ctx.progress(50, 'downloading');
|
||||
return { filePath: '/tmp/video.mp4' };
|
||||
});
|
||||
|
||||
queue.handle('process', async (_job, ctx) => {
|
||||
expect(ctx.phaseResult<{ filePath: string }>('download')?.filePath).toBe('/tmp/video.mp4');
|
||||
await ctx.progress(25, 'processing');
|
||||
return { outputPath: '/tmp/video.txt' };
|
||||
});
|
||||
|
||||
try {
|
||||
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await waitFor(() => queue.getJob(jobId)?.status === 'completed');
|
||||
|
||||
const job = queue.getJob(jobId);
|
||||
expect(job?.status).toBe('completed');
|
||||
expect(job?.phaseResults.download).toEqual({ filePath: '/tmp/video.mp4' });
|
||||
expect(job?.phaseResults.process).toEqual({ outputPath: '/tmp/video.txt' });
|
||||
expect(events).toEqual(['started', 'phase:download', 'phase:process', 'completed']);
|
||||
} finally {
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('retries recoverable failures and eventually completes', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['run'],
|
||||
concurrency: 1,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 10,
|
||||
classifyError: async (error) =>
|
||||
error instanceof Error && error.message === 'recoverable' ? 'recoverable' : 'fatal',
|
||||
},
|
||||
});
|
||||
let attempts = 0;
|
||||
let retries = 0;
|
||||
|
||||
queue.on('job:retrying', () => {
|
||||
retries += 1;
|
||||
});
|
||||
|
||||
queue.handle('run', async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error('recoverable');
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
try {
|
||||
const jobId = await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await waitFor(() => queue.getJob(jobId)?.status === 'completed', { timeoutMs: 4_000 });
|
||||
|
||||
const job = queue.getJob(jobId);
|
||||
expect(job?.status).toBe('completed');
|
||||
expect(job?.retryCount).toBe(1);
|
||||
expect(retries).toBe(1);
|
||||
} finally {
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('streams queue events as SSE', async () => {
|
||||
const dir = createTempDir();
|
||||
const queue = new JobQueue<{ url: string }>({
|
||||
dbPath: createDbPath(dir),
|
||||
phases: ['run'],
|
||||
concurrency: 1,
|
||||
});
|
||||
const stream = queue.createEventStream({ includeSnapshot: false });
|
||||
const reader = stream.getReader();
|
||||
const chunks: string[] = [];
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
chunks.push(new TextDecoder().decode(value));
|
||||
if (chunks.some((chunk) => chunk.includes('event: job:completed'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
queue.handle('run', async (_job, ctx) => {
|
||||
await ctx.progress(100, 'done');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
try {
|
||||
await queue.enqueue({ url: 'https://example.com/video' });
|
||||
await Promise.race([
|
||||
readPromise,
|
||||
waitFor(() => chunks.some((chunk) => chunk.includes('event: job:completed')), {
|
||||
timeoutMs: 4_000,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
await queue.shutdown();
|
||||
cleanupDir(dir);
|
||||
}
|
||||
|
||||
expect(chunks.join('\n')).toContain('event: job:completed');
|
||||
expect(chunks.join('\n')).toContain('event: job:progress');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user