Files
wifi-densepose/npm/packages/ruvbot/tests/unit/workers/background-workers.test.ts
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

819 lines
22 KiB
TypeScript

/**
* Background Workers - Unit Tests
*
* Tests for background job processing, scheduling, and lifecycle
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Worker Types
interface Job {
id: string;
type: string;
payload: unknown;
priority: 'low' | 'normal' | 'high' | 'critical';
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
attempts: number;
maxAttempts: number;
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
error?: string;
result?: unknown;
}
interface WorkerConfig {
concurrency: number;
pollInterval: number;
maxJobDuration: number;
retryDelay: number;
}
type JobHandler = (job: Job) => Promise<unknown>;
// Mock Worker Queue for testing
class WorkerQueue {
private jobs: Map<string, Job> = new Map();
private handlers: Map<string, JobHandler> = new Map();
private running: Map<string, Promise<void>> = new Map();
private config: WorkerConfig;
private isProcessing: boolean = false;
private processInterval?: NodeJS.Timeout;
private eventHandlers: Map<string, Array<(event: unknown) => void>> = new Map();
constructor(config: Partial<WorkerConfig> = {}) {
this.config = {
concurrency: config.concurrency ?? 3,
pollInterval: config.pollInterval ?? 100,
maxJobDuration: config.maxJobDuration ?? 30000,
retryDelay: config.retryDelay ?? 1000
};
}
registerHandler(type: string, handler: JobHandler): void {
this.handlers.set(type, handler);
}
async enqueue(
type: string,
payload: unknown,
options: Partial<Pick<Job, 'priority' | 'maxAttempts'>> = {}
): Promise<Job> {
const job: Job = {
id: `job-${Date.now()}-${Math.random().toString(36).slice(2)}`,
type,
payload,
priority: options.priority ?? 'normal',
status: 'pending',
attempts: 0,
maxAttempts: options.maxAttempts ?? 3,
createdAt: new Date()
};
this.jobs.set(job.id, job);
this.emit('enqueued', job);
return job;
}
async getJob(id: string): Promise<Job | null> {
return this.jobs.get(id) || null;
}
async cancelJob(id: string): Promise<boolean> {
const job = this.jobs.get(id);
if (!job) return false;
if (job.status === 'pending') {
job.status = 'cancelled';
this.emit('cancelled', job);
return true;
}
return false;
}
async retryJob(id: string): Promise<boolean> {
const job = this.jobs.get(id);
if (!job) return false;
if (job.status === 'failed') {
job.status = 'pending';
job.attempts = 0;
job.error = undefined;
this.emit('retried', job);
return true;
}
return false;
}
start(): void {
if (this.isProcessing) return;
this.isProcessing = true;
this.processInterval = setInterval(() => this.processJobs(), this.config.pollInterval);
this.emit('started', {});
}
stop(): void {
if (!this.isProcessing) return;
this.isProcessing = false;
if (this.processInterval) {
clearInterval(this.processInterval);
this.processInterval = undefined;
}
this.emit('stopped', {});
}
async drain(): Promise<void> {
// Wait for all running jobs to complete
await Promise.all(this.running.values());
}
async flush(): Promise<number> {
let count = 0;
for (const [id, job] of this.jobs) {
if (job.status === 'pending' || job.status === 'failed') {
this.jobs.delete(id);
count++;
}
}
return count;
}
getStats(): {
pending: number;
running: number;
completed: number;
failed: number;
cancelled: number;
} {
const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
for (const job of this.jobs.values()) {
stats[job.status]++;
}
return stats;
}
getJobsByStatus(status: Job['status']): Job[] {
return Array.from(this.jobs.values()).filter(j => j.status === status);
}
on(event: string, handler: (event: unknown) => void): void {
const handlers = this.eventHandlers.get(event) || [];
handlers.push(handler);
this.eventHandlers.set(event, handlers);
}
off(event: string, handler: (event: unknown) => void): void {
const handlers = this.eventHandlers.get(event) || [];
this.eventHandlers.set(event, handlers.filter(h => h !== handler));
}
private emit(event: string, data: unknown): void {
const handlers = this.eventHandlers.get(event) || [];
handlers.forEach(h => h(data));
}
private async processJobs(): Promise<void> {
if (this.running.size >= this.config.concurrency) return;
const pendingJobs = this.getPendingJobs();
const slotsAvailable = this.config.concurrency - this.running.size;
for (let i = 0; i < Math.min(pendingJobs.length, slotsAvailable); i++) {
const job = pendingJobs[i];
this.processJob(job);
}
}
private getPendingJobs(): Job[] {
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
return Array.from(this.jobs.values())
.filter(j => j.status === 'pending')
.sort((a, b) => {
// Sort by priority first, then by creation time
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (priorityDiff !== 0) return priorityDiff;
return a.createdAt.getTime() - b.createdAt.getTime();
});
}
private async processJob(job: Job): Promise<void> {
const handler = this.handlers.get(job.type);
if (!handler) {
job.status = 'failed';
job.error = `No handler registered for job type: ${job.type}`;
this.emit('failed', job);
return;
}
job.status = 'running';
job.startedAt = new Date();
job.attempts++;
this.emit('started', job);
const promise = this.executeJob(job, handler);
this.running.set(job.id, promise);
try {
await promise;
} finally {
this.running.delete(job.id);
}
}
private async executeJob(job: Job, handler: JobHandler): Promise<void> {
try {
const result = await Promise.race([
handler(job),
this.createTimeout(this.config.maxJobDuration)
]);
job.status = 'completed';
job.completedAt = new Date();
job.result = result;
this.emit('completed', job);
} catch (error) {
job.error = error instanceof Error ? error.message : 'Unknown error';
if (job.attempts < job.maxAttempts) {
job.status = 'pending';
// Schedule retry after delay
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay));
} else {
job.status = 'failed';
this.emit('failed', job);
}
}
}
private async createTimeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Job timed out')), ms);
});
}
}
// Scheduled Worker for periodic tasks
class ScheduledWorker {
private tasks: Map<string, {
interval: number;
handler: () => Promise<void>;
timer?: NodeJS.Timeout;
lastRun?: Date;
isRunning: boolean;
}> = new Map();
private isActive: boolean = false;
schedule(
taskId: string,
interval: number,
handler: () => Promise<void>
): void {
this.tasks.set(taskId, {
interval,
handler,
isRunning: false
});
if (this.isActive) {
this.startTask(taskId);
}
}
unschedule(taskId: string): boolean {
const task = this.tasks.get(taskId);
if (!task) return false;
if (task.timer) {
clearInterval(task.timer);
}
return this.tasks.delete(taskId);
}
start(): void {
if (this.isActive) return;
this.isActive = true;
for (const taskId of this.tasks.keys()) {
this.startTask(taskId);
}
}
stop(): void {
if (!this.isActive) return;
this.isActive = false;
for (const [, task] of this.tasks) {
if (task.timer) {
clearInterval(task.timer);
task.timer = undefined;
}
}
}
async runNow(taskId: string): Promise<void> {
const task = this.tasks.get(taskId);
if (!task) throw new Error(`Task ${taskId} not found`);
if (task.isRunning) {
throw new Error(`Task ${taskId} is already running`);
}
task.isRunning = true;
try {
await task.handler();
task.lastRun = new Date();
} finally {
task.isRunning = false;
}
}
getTaskInfo(taskId: string): {
interval: number;
lastRun?: Date;
isRunning: boolean;
} | null {
const task = this.tasks.get(taskId);
if (!task) return null;
return {
interval: task.interval,
lastRun: task.lastRun,
isRunning: task.isRunning
};
}
listTasks(): string[] {
return Array.from(this.tasks.keys());
}
private startTask(taskId: string): void {
const task = this.tasks.get(taskId);
if (!task) return;
task.timer = setInterval(async () => {
if (task.isRunning) return;
task.isRunning = true;
try {
await task.handler();
task.lastRun = new Date();
} catch (error) {
// Log error but don't stop the schedule
console.error(`Scheduled task ${taskId} failed:`, error);
} finally {
task.isRunning = false;
}
}, task.interval);
}
}
// Tests
describe('Worker Queue', () => {
let queue: WorkerQueue;
beforeEach(() => {
queue = new WorkerQueue({
concurrency: 2,
pollInterval: 10,
maxJobDuration: 5000,
retryDelay: 50
});
});
afterEach(() => {
queue.stop();
});
describe('Job Enqueuing', () => {
it('should enqueue job with default options', async () => {
const job = await queue.enqueue('test-job', { data: 'test' });
expect(job.id).toBeDefined();
expect(job.type).toBe('test-job');
expect(job.status).toBe('pending');
expect(job.priority).toBe('normal');
expect(job.attempts).toBe(0);
expect(job.maxAttempts).toBe(3);
});
it('should enqueue job with custom options', async () => {
const job = await queue.enqueue('urgent-job', { data: 'urgent' }, {
priority: 'high',
maxAttempts: 5
});
expect(job.priority).toBe('high');
expect(job.maxAttempts).toBe(5);
});
it('should emit enqueued event', async () => {
const handler = vi.fn();
queue.on('enqueued', handler);
await queue.enqueue('test-job', {});
expect(handler).toHaveBeenCalled();
});
});
describe('Job Retrieval', () => {
it('should get job by ID', async () => {
const created = await queue.enqueue('test', {});
const retrieved = await queue.getJob(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(created.id);
});
it('should return null for non-existent job', async () => {
const job = await queue.getJob('non-existent');
expect(job).toBeNull();
});
});
describe('Job Processing', () => {
it('should process jobs with registered handler', async () => {
const handler = vi.fn().mockResolvedValue({ success: true });
queue.registerHandler('test-job', handler);
await queue.enqueue('test-job', { data: 'test' });
queue.start();
await new Promise(resolve => setTimeout(resolve, 50));
expect(handler).toHaveBeenCalled();
});
it('should mark job as completed on success', async () => {
queue.registerHandler('test-job', async () => ({ result: 'done' }));
const job = await queue.enqueue('test-job', {});
queue.start();
await new Promise(resolve => setTimeout(resolve, 50));
const updated = await queue.getJob(job.id);
expect(updated?.status).toBe('completed');
expect(updated?.result).toEqual({ result: 'done' });
});
it('should mark job as failed when no handler exists', async () => {
const job = await queue.enqueue('unknown-job', {});
queue.start();
await new Promise(resolve => setTimeout(resolve, 50));
const updated = await queue.getJob(job.id);
expect(updated?.status).toBe('failed');
expect(updated?.error).toContain('No handler registered');
});
it('should retry failed jobs', async () => {
let attempts = 0;
queue.registerHandler('flaky-job', async () => {
attempts++;
if (attempts < 2) throw new Error('Temporary failure');
return { success: true };
});
const job = await queue.enqueue('flaky-job', {}, { maxAttempts: 3 });
queue.start();
await new Promise(resolve => setTimeout(resolve, 200));
const updated = await queue.getJob(job.id);
expect(updated?.status).toBe('completed');
expect(attempts).toBe(2);
});
it('should mark job as failed after max attempts', async () => {
queue.registerHandler('always-fail', async () => {
throw new Error('Always fails');
});
const job = await queue.enqueue('always-fail', {}, { maxAttempts: 2 });
queue.start();
await new Promise(resolve => setTimeout(resolve, 200));
const updated = await queue.getJob(job.id);
expect(updated?.status).toBe('failed');
expect(updated?.attempts).toBe(2);
});
it('should respect concurrency limit', async () => {
let concurrent = 0;
let maxConcurrent = 0;
queue.registerHandler('concurrent-job', async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise(resolve => setTimeout(resolve, 50));
concurrent--;
return {};
});
// Enqueue more jobs than concurrency limit
for (let i = 0; i < 5; i++) {
await queue.enqueue('concurrent-job', { index: i });
}
queue.start();
await new Promise(resolve => setTimeout(resolve, 300));
expect(maxConcurrent).toBeLessThanOrEqual(2);
});
it('should process high priority jobs first', async () => {
const processOrder: string[] = [];
queue.registerHandler('priority-job', async (job) => {
processOrder.push(job.payload as string);
return {};
});
await queue.enqueue('priority-job', 'low', { priority: 'low' });
await queue.enqueue('priority-job', 'high', { priority: 'high' });
await queue.enqueue('priority-job', 'critical', { priority: 'critical' });
await queue.enqueue('priority-job', 'normal', { priority: 'normal' });
queue.start();
await new Promise(resolve => setTimeout(resolve, 100));
expect(processOrder[0]).toBe('critical');
expect(processOrder[1]).toBe('high');
});
});
describe('Job Cancellation', () => {
it('should cancel pending job', async () => {
const job = await queue.enqueue('test', {});
const cancelled = await queue.cancelJob(job.id);
const updated = await queue.getJob(job.id);
expect(cancelled).toBe(true);
expect(updated?.status).toBe('cancelled');
});
it('should not cancel running job', async () => {
queue.registerHandler('long-job', async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
return {};
});
const job = await queue.enqueue('long-job', {});
queue.start();
await new Promise(resolve => setTimeout(resolve, 20));
const cancelled = await queue.cancelJob(job.id);
expect(cancelled).toBe(false);
});
});
describe('Job Retry', () => {
it('should retry failed job', async () => {
queue.registerHandler('retry-job', async () => {
throw new Error('Fail');
});
const job = await queue.enqueue('retry-job', {}, { maxAttempts: 1 });
queue.start();
await new Promise(resolve => setTimeout(resolve, 100));
let updated = await queue.getJob(job.id);
expect(updated?.status).toBe('failed');
// Make handler succeed now
queue.registerHandler('retry-job', async () => ({ success: true }));
const retried = await queue.retryJob(job.id);
expect(retried).toBe(true);
await new Promise(resolve => setTimeout(resolve, 100));
updated = await queue.getJob(job.id);
expect(updated?.status).toBe('completed');
});
});
describe('Queue Management', () => {
it('should start and stop processing', () => {
const startHandler = vi.fn();
const stopHandler = vi.fn();
queue.on('started', startHandler);
queue.on('stopped', stopHandler);
queue.start();
expect(startHandler).toHaveBeenCalled();
queue.stop();
expect(stopHandler).toHaveBeenCalled();
});
it('should drain running jobs', async () => {
let completed = 0;
queue.registerHandler('drain-job', async () => {
await new Promise(resolve => setTimeout(resolve, 50));
completed++;
return {};
});
await queue.enqueue('drain-job', {});
await queue.enqueue('drain-job', {});
queue.start();
await new Promise(resolve => setTimeout(resolve, 20));
await queue.drain();
expect(completed).toBe(2);
});
it('should flush pending and failed jobs', async () => {
await queue.enqueue('test', {});
await queue.enqueue('test', {});
const flushed = await queue.flush();
expect(flushed).toBe(2);
});
it('should get queue stats', async () => {
queue.registerHandler('stat-job', async () => ({}));
await queue.enqueue('stat-job', {});
await queue.enqueue('stat-job', {});
const stats = queue.getStats();
expect(stats.pending).toBe(2);
expect(stats.running).toBe(0);
expect(stats.completed).toBe(0);
});
it('should get jobs by status', async () => {
queue.registerHandler('status-job', async () => ({}));
await queue.enqueue('status-job', {});
await queue.enqueue('status-job', {});
const pending = queue.getJobsByStatus('pending');
expect(pending).toHaveLength(2);
});
});
});
describe('Scheduled Worker', () => {
let scheduler: ScheduledWorker;
beforeEach(() => {
scheduler = new ScheduledWorker();
});
afterEach(() => {
scheduler.stop();
});
describe('Task Scheduling', () => {
it('should schedule task', () => {
const handler = vi.fn().mockResolvedValue(undefined);
scheduler.schedule('task-1', 100, handler);
const tasks = scheduler.listTasks();
expect(tasks).toContain('task-1');
});
it('should unschedule task', () => {
scheduler.schedule('task-1', 100, vi.fn());
const result = scheduler.unschedule('task-1');
expect(result).toBe(true);
expect(scheduler.listTasks()).not.toContain('task-1');
});
it('should run scheduled task periodically', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
scheduler.schedule('periodic', 50, handler);
scheduler.start();
await new Promise(resolve => setTimeout(resolve, 120));
expect(handler).toHaveBeenCalledTimes(2);
});
it('should not run task concurrently with itself', async () => {
let concurrent = 0;
let maxConcurrent = 0;
scheduler.schedule('non-concurrent', 10, async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise(resolve => setTimeout(resolve, 50));
concurrent--;
});
scheduler.start();
await new Promise(resolve => setTimeout(resolve, 100));
expect(maxConcurrent).toBe(1);
});
});
describe('Manual Execution', () => {
it('should run task immediately', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
scheduler.schedule('immediate', 10000, handler);
await scheduler.runNow('immediate');
expect(handler).toHaveBeenCalledTimes(1);
});
it('should throw when task not found', async () => {
await expect(scheduler.runNow('non-existent'))
.rejects.toThrow('not found');
});
it('should throw when task is already running', async () => {
scheduler.schedule('running', 10000, async () => {
await new Promise(resolve => setTimeout(resolve, 100));
});
const promise = scheduler.runNow('running');
await expect(scheduler.runNow('running'))
.rejects.toThrow('already running');
await promise;
});
});
describe('Task Info', () => {
it('should get task info', () => {
scheduler.schedule('info-task', 1000, vi.fn());
const info = scheduler.getTaskInfo('info-task');
expect(info).not.toBeNull();
expect(info?.interval).toBe(1000);
expect(info?.isRunning).toBe(false);
});
it('should track last run time', async () => {
scheduler.schedule('tracked', 10000, vi.fn());
await scheduler.runNow('tracked');
const info = scheduler.getTaskInfo('tracked');
expect(info?.lastRun).toBeInstanceOf(Date);
});
it('should return null for non-existent task', () => {
const info = scheduler.getTaskInfo('non-existent');
expect(info).toBeNull();
});
});
describe('Lifecycle', () => {
it('should start all scheduled tasks', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
scheduler.schedule('task-1', 10000, handler1);
scheduler.schedule('task-2', 10000, handler2);
scheduler.start();
// Tasks are scheduled (not run immediately)
expect(scheduler.listTasks()).toHaveLength(2);
});
it('should stop all scheduled tasks', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
scheduler.schedule('stopped', 20, handler);
scheduler.start();
await new Promise(resolve => setTimeout(resolve, 50));
const countBeforeStop = handler.mock.calls.length;
scheduler.stop();
await new Promise(resolve => setTimeout(resolve, 50));
expect(handler.mock.calls.length).toBe(countBeforeStop);
});
});
});