@pulzar/core
Version:
Next-generation Node.js framework for ultra-fast web applications with zero-reflection DI, GraphQL, WebSockets, events, and edge runtime support
1,022 lines • 34.1 kB
JavaScript
import { EventEmitter } from "events";
import { logger } from "../utils/logger";
const cronParser = require("cron-parser");
export class TaskAdapter extends EventEmitter {
}
export class TaskScheduler extends EventEmitter {
adapter;
tasks = new Map();
cronJobs = new Map();
queues = new Set();
options;
connected = false;
constructor(options) {
super();
const defaultOptions = {
adapter: "memory",
redis: {
host: "localhost",
port: 6379,
db: 0,
},
postgres: {
connectionString: "postgresql://localhost:5432/tasks",
},
defaultRetries: 3,
defaultTimeout: 30000,
enableMetrics: true,
};
this.options = {
...defaultOptions,
...options,
};
this.adapter = this.createAdapter();
}
/**
* Create adapter instance based on configuration
*/
createAdapter() {
switch (this.options.adapter) {
case "memory":
return new MemoryTaskAdapter();
case "bullmq":
return new BullMQAdapter(this.options.redis);
case "pgboss":
return new PgBossAdapter(this.options.postgres);
default:
throw new Error(`Unsupported task adapter: ${this.options.adapter}`);
}
}
/**
* Initialize task scheduler
*/
async initialize() {
if (this.connected) {
throw new Error("TaskScheduler already initialized");
}
logger.info("Initializing task scheduler", {
adapter: this.options.adapter,
tasks: this.tasks.size,
});
try {
await this.adapter.connect();
this.connected = true;
// Setup cron jobs
await this.setupCronJobs();
// Setup queue processors
await this.setupQueueProcessors();
this.emit("initialized");
logger.info("Task scheduler initialized", {
cronJobs: this.cronJobs.size,
queues: this.queues.size,
});
}
catch (error) {
logger.error("Failed to initialize task scheduler", { error });
throw error;
}
}
/**
* Register a task
*/
registerTask(metadata, handler) {
if (this.tasks.has(metadata.name)) {
throw new Error(`Task "${metadata.name}" is already registered`);
}
const taskConfig = {
retries: this.options.defaultRetries,
timeout: this.options.defaultTimeout,
enabled: true,
...metadata,
handler,
};
this.tasks.set(metadata.name, taskConfig);
if (metadata.queue) {
this.queues.add(metadata.queue);
}
logger.debug("Task registered", {
name: metadata.name,
cron: metadata.cron,
queue: metadata.queue,
});
}
/**
* Setup cron jobs
*/
async setupCronJobs() {
for (const [name, task] of this.tasks) {
if (task.cron && task.enabled) {
try {
const interval = cronParser.parseExpression(task.cron);
const nextRun = interval.next().toDate();
const delay = nextRun.getTime() - Date.now();
const timeout = setTimeout(async () => {
await this.executeCronTask(name, task);
}, delay);
this.cronJobs.set(name, timeout);
logger.info("Cron job scheduled", {
task: name,
cron: task.cron,
nextRun: nextRun.toISOString(),
});
}
catch (error) {
logger.error("Failed to schedule cron job", {
task: name,
cron: task.cron,
error,
});
}
}
}
}
/**
* Execute cron task and reschedule
*/
async executeCronTask(name, task) {
try {
logger.debug("Executing cron task", { task: name });
await Promise.race([
task.handler(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Task timeout")), task.timeout)),
]);
logger.info("Cron task completed", { task: name });
this.emit("taskCompleted", { name, type: "cron" });
}
catch (error) {
logger.error("Cron task failed", { task: name, error });
this.emit("taskFailed", { name, type: "cron", error });
}
// Reschedule for next execution
if (task.cron && task.enabled) {
try {
const interval = cronParser.parseExpression(task.cron);
const nextRun = interval.next().toDate();
const delay = nextRun.getTime() - Date.now();
const timeout = setTimeout(async () => {
await this.executeCronTask(name, task);
}, delay);
this.cronJobs.set(name, timeout);
}
catch (error) {
logger.error("Failed to reschedule cron task", { task: name, error });
}
}
}
/**
* Setup queue processors
*/
async setupQueueProcessors() {
for (const queueName of this.queues) {
await this.adapter.processQueue(queueName, async (data) => {
const task = Array.from(this.tasks.values()).find((t) => t.queue === queueName);
if (task) {
await task.handler(data);
}
});
}
}
/**
* Add job to queue
*/
async addJob(queueName, data, options = {}) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
const job = {
id: this.generateJobId(),
name: queueName,
data,
options: {
retries: this.options.defaultRetries,
timeout: this.options.defaultTimeout,
...options,
},
attempts: 0,
};
await this.adapter.scheduleJob(job);
logger.debug("Job added to queue", {
jobId: job.id,
queue: queueName,
priority: options.priority,
});
this.emit("jobAdded", job);
return job.id;
}
/**
* Get queue information
*/
async getQueueInfo(queueName) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
return this.adapter.getQueueInfo(queueName);
}
/**
* Remove job from queue
*/
async removeJob(jobId) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
const removed = await this.adapter.removeJob(jobId);
if (removed) {
logger.info("Job removed", { jobId });
this.emit("jobRemoved", { jobId });
}
return removed;
}
/**
* Retry failed job
*/
async retryJob(jobId) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
const retried = await this.adapter.retryJob(jobId);
if (retried) {
logger.info("Job retried", { jobId });
this.emit("jobRetried", { jobId });
}
return retried;
}
/**
* Pause queue processing
*/
async pauseQueue(queueName) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
await this.adapter.pauseQueue(queueName);
logger.info("Queue paused", { queue: queueName });
this.emit("queuePaused", { queueName });
}
/**
* Resume queue processing
*/
async resumeQueue(queueName) {
if (!this.connected) {
throw new Error("TaskScheduler not connected");
}
await this.adapter.resumeQueue(queueName);
logger.info("Queue resumed", { queue: queueName });
this.emit("queueResumed", { queueName });
}
/**
* Enable/disable a task
*/
setTaskEnabled(taskName, enabled) {
const task = this.tasks.get(taskName);
if (!task) {
throw new Error(`Task "${taskName}" not found`);
}
task.enabled = enabled;
if (!enabled && task.cron) {
// Cancel cron job
const timeout = this.cronJobs.get(taskName);
if (timeout) {
clearTimeout(timeout);
this.cronJobs.delete(taskName);
}
}
else if (enabled && task.cron) {
// Reschedule cron job
this.setupCronJobForTask(taskName, task);
}
logger.info("Task enabled state changed", { task: taskName, enabled });
}
/**
* Setup cron job for a specific task
*/
setupCronJobForTask(name, task) {
if (!task.cron)
return;
try {
const interval = cronParser.parseExpression(task.cron);
const nextRun = interval.next().toDate();
const delay = nextRun.getTime() - Date.now();
const timeout = setTimeout(async () => {
await this.executeCronTask(name, task);
}, delay);
this.cronJobs.set(name, timeout);
}
catch (error) {
logger.error("Failed to setup cron job", { task: name, error });
}
}
/**
* Get scheduler statistics
*/
getStats() {
return {
connected: this.connected,
adapter: this.options.adapter,
totalTasks: this.tasks.size,
cronJobs: this.cronJobs.size,
queues: this.queues.size,
enabledTasks: Array.from(this.tasks.values()).filter((t) => t.enabled)
.length,
};
}
/**
* Generate unique job ID
*/
generateJobId() {
return `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Shutdown task scheduler
*/
async shutdown() {
if (!this.connected) {
return;
}
logger.info("Shutting down task scheduler");
// Clear all cron jobs
for (const timeout of this.cronJobs.values()) {
clearTimeout(timeout);
}
this.cronJobs.clear();
// Disconnect adapter
await this.adapter.disconnect();
this.connected = false;
this.emit("shutdown");
logger.info("Task scheduler shutdown complete");
}
/**
* Check if scheduler is connected
*/
isConnected() {
return this.connected && this.adapter.isConnected();
}
}
/**
* Memory adapter for local task processing
*/
export class MemoryTaskAdapter extends TaskAdapter {
jobs = new Map();
processors = new Map();
paused = new Set();
connected = false;
async connect() {
this.connected = true;
}
async disconnect() {
this.jobs.clear();
this.processors.clear();
this.paused.clear();
this.connected = false;
}
async scheduleJob(job) {
this.jobs.set(job.id, job);
// Process immediately if not paused
if (!this.paused.has(job.name)) {
setTimeout(() => this.processJob(job), job.options.delay || 0);
}
}
async processJob(job) {
const processor = this.processors.get(job.name);
if (!processor) {
logger.warn("No processor for queue", { queue: job.name });
return;
}
try {
job.processedOn = new Date();
await processor(job.data);
job.finishedOn = new Date();
this.emit("jobCompleted", job);
}
catch (error) {
job.failedReason = error instanceof Error ? error.message : String(error);
job.attempts++;
if (job.attempts < (job.options.retries || 0)) {
// Retry job
setTimeout(() => this.processJob(job), 1000 * job.attempts);
}
else {
this.emit("jobFailed", job);
}
}
}
async processQueue(queueName, handler) {
this.processors.set(queueName, handler);
}
async getQueueInfo(queueName) {
const queueJobs = Array.from(this.jobs.values()).filter((j) => j.name === queueName);
return {
name: queueName,
waiting: queueJobs.filter((j) => !j.processedOn).length,
active: queueJobs.filter((j) => j.processedOn && !j.finishedOn).length,
completed: queueJobs.filter((j) => j.finishedOn).length,
failed: queueJobs.filter((j) => j.failedReason).length,
paused: this.paused.has(queueName),
};
}
async removeJob(jobId) {
return this.jobs.delete(jobId);
}
async retryJob(jobId) {
const job = this.jobs.get(jobId);
if (job && job.failedReason) {
job.failedReason = undefined;
job.attempts = 0;
setTimeout(() => this.processJob(job), 0);
return true;
}
return false;
}
async pauseQueue(queueName) {
this.paused.add(queueName);
}
async resumeQueue(queueName) {
this.paused.delete(queueName);
// Process pending jobs
const pendingJobs = Array.from(this.jobs.values()).filter((j) => j.name === queueName && !j.processedOn);
for (const job of pendingJobs) {
setTimeout(() => this.processJob(job), 0);
}
}
isConnected() {
return this.connected;
}
}
/**
* BullMQ adapter (placeholder - requires bullmq package)
*/
export class BullMQAdapter extends TaskAdapter {
client;
queues = new Map();
workers = new Map();
options;
connected = false;
constructor(options) {
super();
this.options = options;
}
async connect() {
try {
// Try to load BullMQ dynamically
try {
const bullmqModule = await this.dynamicImportBullMQ();
if (bullmqModule) {
const { Queue, Worker } = bullmqModule;
// Initialize Redis connection for BullMQ
const IORedis = await this.dynamicImportIORedis();
if (IORedis) {
this.client = new IORedis.default(this.options.redis);
}
logger.info("BullMQ adapter connected", {
redis: this.options.redis?.host || "localhost",
});
}
else {
throw new Error("BullMQ not available");
}
}
catch (importError) {
// Fallback to mock implementation for development
logger.warn("BullMQ package not installed, using mock implementation");
this.createMockBullMQ();
}
this.connected = true;
}
catch (error) {
logger.error("Failed to connect to BullMQ", { error });
throw error;
}
}
async disconnect() {
try {
// Cleanup queues and workers
for (const [name, queue] of this.queues.entries()) {
if (queue.close) {
await queue.close();
}
}
for (const [name, worker] of this.workers.entries()) {
if (worker.close) {
await worker.close();
}
}
if (this.client && this.client.disconnect) {
await this.client.disconnect();
}
this.queues.clear();
this.workers.clear();
this.connected = false;
logger.info("BullMQ adapter disconnected");
}
catch (error) {
logger.warn("Error disconnecting from BullMQ", { error });
this.connected = false;
}
}
async scheduleJob(job) {
if (!this.connected) {
throw new Error("BullMQ not connected");
}
try {
// Use job name as queue name for BullMQ
const queueName = job.name;
let queue = this.queues.get(queueName);
if (!queue) {
if (this.client) {
// Real BullMQ implementation
const { Queue } = await this.dynamicImportBullMQ();
queue = new Queue(queueName, { connection: this.client });
this.queues.set(queueName, queue);
}
else {
// Mock implementation
queue = { add: async () => ({ id: job.id }) };
this.queues.set(queueName, queue);
}
}
const jobOptions = {
delay: job.options.delay,
attempts: job.options.retries || 3,
backoff: "exponential",
};
await queue.add(job.name, job.data, jobOptions);
logger.debug("BullMQ job scheduled", {
jobId: job.id,
queue: queueName,
name: job.name,
});
}
catch (error) {
logger.error("Failed to schedule BullMQ job", { job, error });
throw error;
}
}
async processQueue(queueName, handler) {
if (!this.connected) {
throw new Error("BullMQ not connected");
}
try {
let worker = this.workers.get(queueName);
if (!worker) {
if (this.client) {
// Real BullMQ implementation
const { Worker } = await this.dynamicImportBullMQ();
worker = new Worker(queueName, async (job) => {
const queueJob = {
id: job.id,
name: job.name,
data: job.data,
options: {
retries: job.opts?.attempts || 3,
timeout: job.opts?.timeout,
},
attempts: job.attemptsMade || 0,
processedOn: job.processedOn
? new Date(job.processedOn)
: undefined,
finishedOn: job.finishedOn
? new Date(job.finishedOn)
: undefined,
failedReason: job.failedReason,
};
return await handler(queueJob);
}, { connection: this.client });
this.workers.set(queueName, worker);
}
else {
// Mock implementation
worker = { process: async () => { } };
this.workers.set(queueName, worker);
}
}
logger.debug("BullMQ queue processor started", { queue: queueName });
}
catch (error) {
logger.error("Failed to process BullMQ queue", { queueName, error });
throw error;
}
}
async getQueueInfo(queueName) {
try {
const queue = this.queues.get(queueName);
if (queue && queue.getWaiting) {
// Real BullMQ implementation
const [waiting, active, completed, failed] = await Promise.all([
queue.getWaiting(),
queue.getActive(),
queue.getCompleted(),
queue.getFailed(),
]);
return {
name: queueName,
waiting: waiting.length,
active: active.length,
completed: completed.length,
failed: failed.length,
};
}
else {
// Mock implementation
return {
name: queueName,
waiting: 0,
active: 0,
completed: 0,
failed: 0,
};
}
}
catch (error) {
logger.error("Failed to get BullMQ queue info", { queueName, error });
return {
name: queueName,
waiting: 0,
active: 0,
completed: 0,
failed: 0,
};
}
}
async removeJob(jobId) {
try {
// Find job across all queues
for (const [queueName, queue] of this.queues.entries()) {
if (queue.getJob) {
const job = await queue.getJob(jobId);
if (job && job.remove) {
await job.remove();
logger.debug("BullMQ job removed", { jobId, queue: queueName });
return true;
}
}
}
logger.debug("BullMQ job not found for removal", { jobId });
return false;
}
catch (error) {
logger.error("Failed to remove BullMQ job", { jobId, error });
return false;
}
}
async retryJob(jobId) {
try {
// Find job across all queues
for (const [queueName, queue] of this.queues.entries()) {
if (queue.getJob) {
const job = await queue.getJob(jobId);
if (job && job.retry) {
await job.retry();
logger.debug("BullMQ job retried", { jobId, queue: queueName });
return true;
}
}
}
logger.debug("BullMQ job not found for retry", { jobId });
return false;
}
catch (error) {
logger.error("Failed to retry BullMQ job", { jobId, error });
return false;
}
}
async pauseQueue(queueName) {
try {
const queue = this.queues.get(queueName);
if (queue && queue.pause) {
await queue.pause();
logger.debug("BullMQ queue paused", { queue: queueName });
}
else {
logger.debug("BullMQ queue pause (mock)", { queue: queueName });
}
}
catch (error) {
logger.error("Failed to pause BullMQ queue", { queueName, error });
throw error;
}
}
async resumeQueue(queueName) {
try {
const queue = this.queues.get(queueName);
if (queue && queue.resume) {
await queue.resume();
logger.debug("BullMQ queue resumed", { queue: queueName });
}
else {
logger.debug("BullMQ queue resume (mock)", { queue: queueName });
}
}
catch (error) {
logger.error("Failed to resume BullMQ queue", { queueName, error });
throw error;
}
}
async dynamicImportBullMQ() {
try {
return await new Function('return import("bullmq")')();
}
catch {
return null;
}
}
async dynamicImportIORedis() {
try {
return await new Function('return import("ioredis")')();
}
catch {
return null;
}
}
createMockBullMQ() {
const mockJobs = new Map();
// Mock implementation for development
logger.info("Using mock BullMQ implementation");
}
isConnected() {
return this.connected;
}
}
/**
* PgBoss adapter (placeholder - requires pg-boss package)
*/
export class PgBossAdapter extends TaskAdapter {
boss;
options;
connected = false;
constructor(options) {
super();
this.options = options;
}
async connect() {
try {
// Try to load PgBoss dynamically
try {
const pgBossModule = await this.dynamicImportPgBoss();
if (pgBossModule) {
const PgBoss = pgBossModule.default || pgBossModule;
this.boss = new PgBoss(this.options.connectionString);
await this.boss.start();
logger.info("PgBoss adapter connected", {
database: this.options.connectionString.split("@")[1]?.split("/")[1] ||
"unknown",
});
}
else {
throw new Error("pg-boss not available");
}
}
catch (importError) {
// Fallback to mock implementation for development
logger.warn("pg-boss package not installed, using mock implementation");
this.createMockPgBoss();
}
this.connected = true;
}
catch (error) {
logger.error("Failed to connect to PgBoss", { error });
throw error;
}
}
async disconnect() {
try {
if (this.boss && this.boss.stop) {
await this.boss.stop();
}
this.connected = false;
logger.info("PgBoss adapter disconnected");
}
catch (error) {
logger.warn("Error disconnecting from PgBoss", { error });
this.connected = false;
}
}
async scheduleJob(job) {
if (!this.connected) {
throw new Error("PgBoss not connected");
}
try {
const jobOptions = {
priority: job.options.priority || 0,
retryLimit: job.options.retries || 3,
retryDelay: 30, // seconds
expireInMinutes: job.options.timeout
? Math.ceil(job.options.timeout / 60000)
: 60,
};
if (job.options.delay) {
jobOptions.startAfter = new Date(Date.now() + job.options.delay);
}
if (this.boss && this.boss.send) {
await this.boss.send(job.name, job.data, jobOptions);
logger.debug("PgBoss job scheduled", {
jobId: job.id,
queue: job.name,
priority: jobOptions.priority,
});
}
else {
// Mock implementation
logger.debug("PgBoss schedule job (mock)", { jobId: job.id });
}
}
catch (error) {
logger.error("Failed to schedule PgBoss job", { job, error });
throw error;
}
}
async processQueue(queueName, handler) {
if (!this.connected) {
throw new Error("PgBoss not connected");
}
try {
if (this.boss && this.boss.work) {
await this.boss.work(queueName, async (job) => {
const queueJob = {
id: job.id,
name: job.name,
data: job.data,
options: {
retries: job.retryLimit || 3,
timeout: job.expireInMinutes
? job.expireInMinutes * 60000
: undefined,
},
attempts: job.retryCount || 0,
processedOn: job.startedOn ? new Date(job.startedOn) : undefined,
finishedOn: job.completedOn ? new Date(job.completedOn) : undefined,
failedReason: job.output?.error,
};
try {
const result = await handler(queueJob);
return result;
}
catch (error) {
logger.error("PgBoss job processing failed", {
jobId: job.id,
queue: queueName,
error,
});
throw error;
}
});
logger.debug("PgBoss queue processor started", { queue: queueName });
}
else {
// Mock implementation
logger.debug("PgBoss process queue (mock)", { queue: queueName });
}
}
catch (error) {
logger.error("Failed to process PgBoss queue", { queueName, error });
throw error;
}
}
async getQueueInfo(queueName) {
try {
if (this.boss && this.boss.getQueueSize) {
const queueSize = await this.boss.getQueueSize(queueName);
return {
name: queueName,
waiting: queueSize,
active: 0, // PgBoss doesn't provide this directly
completed: 0, // Would need custom query
failed: 0, // Would need custom query
};
}
else {
// Mock implementation
return {
name: queueName,
waiting: 0,
active: 0,
completed: 0,
failed: 0,
};
}
}
catch (error) {
logger.error("Failed to get PgBoss queue info", { queueName, error });
return {
name: queueName,
waiting: 0,
active: 0,
completed: 0,
failed: 0,
};
}
}
async removeJob(jobId) {
try {
if (this.boss && this.boss.cancel) {
await this.boss.cancel(jobId);
logger.debug("PgBoss job removed", { jobId });
return true;
}
else {
logger.debug("PgBoss job removal (mock)", { jobId });
return false;
}
}
catch (error) {
logger.error("Failed to remove PgBoss job", { jobId, error });
return false;
}
}
async retryJob(jobId) {
try {
if (this.boss && this.boss.retry) {
await this.boss.retry(jobId);
logger.debug("PgBoss job retried", { jobId });
return true;
}
else {
logger.debug("PgBoss job retry (mock)", { jobId });
return false;
}
}
catch (error) {
logger.error("Failed to retry PgBoss job", { jobId, error });
return false;
}
}
async pauseQueue(queueName) {
try {
// PgBoss doesn't have direct pause/resume, but we can stop workers
logger.debug("PgBoss queue pause (mock)", { queue: queueName });
}
catch (error) {
logger.error("Failed to pause PgBoss queue", { queueName, error });
throw error;
}
}
async resumeQueue(queueName) {
try {
// PgBoss doesn't have direct pause/resume, but we can restart workers
logger.debug("PgBoss queue resume (mock)", { queue: queueName });
}
catch (error) {
logger.error("Failed to resume PgBoss queue", { queueName, error });
throw error;
}
}
async dynamicImportPgBoss() {
try {
return await new Function('return import("pg-boss")')();
}
catch {
return null;
}
}
createMockPgBoss() {
const mockJobs = new Map();
this.boss = {
start: async () => { },
stop: async () => { },
send: async (queue, data, options) => {
const jobId = `pgboss_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
mockJobs.set(jobId, { queue, data, options });
return jobId;
},
work: async (queue, handler) => {
// Mock worker - in real implementation would poll for jobs
},
getQueueSize: async (queue) => 0,
cancel: async (jobId) => mockJobs.delete(jobId),
retry: async (jobId) => mockJobs.has(jobId),
};
logger.info("Using mock PgBoss implementation");
}
isConnected() {
return this.connected;
}
}
/**
* Task decorator for cron jobs
*/
export function Task(cron, options = {}) {
return function (target, propertyKey, descriptor) {
const metadata = {
name: `${target.constructor.name}.${propertyKey}`,
cron,
...options,
};
// Store task metadata
Reflect.defineMetadata("task:metadata", metadata, target, propertyKey);
return descriptor;
};
}
/**
* Queue decorator for queue jobs
*/
export function Queue(queueName, options = {}) {
return function (target, propertyKey, descriptor) {
const metadata = {
name: `${target.constructor.name}.${propertyKey}`,
queue: queueName,
...options,
};
// Store task metadata
Reflect.defineMetadata("task:metadata", metadata, target, propertyKey);
return descriptor;
};
}
export default TaskScheduler;
//# sourceMappingURL=task-scheduler.js.map