@sturmfrei/litequu
Version:
A simple same-thread queuing system for Node.js using SQLite with retry mechanism and exponential backoff
595 lines (520 loc) • 19.3 kB
JavaScript
import { EventEmitter } from 'events';
import Database from './db.js';
/**
* Job class representing a named worker type with its own processor function.
* Extends EventEmitter to provide event-based notifications for job-specific task lifecycle events.
*
* @extends EventEmitter
* @fires Job#added - When a task is added to this job
* @fires Job#completed - When a task completes successfully
* @fires Job#failed - When a task fails after all retries
* @fires Job#retried - When a task is scheduled for retry
*/
class Job extends EventEmitter {
/**
* Creates a new Job instance.
* @param {Queue} queue - Reference to the parent Queue instance
* @param {string} name - Name of this job type
*/
constructor(queue, name) {
super();
this.queue = queue;
this.name = name;
this.handler = null;
}
/**
* Registers a handler function for this job and starts processing if autoProcess is enabled.
* @param {Function} handler - Function to process tasks for this job, receives task data as parameter
* @returns {Promise<void>} Promise that resolves after initial processing setup
* @throws {Error} When handler is not a function
*/
async process(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
this.handler = handler;
// Register this job with the queue
this.queue._registerJob(this);
// If autoProcess is enabled, trigger processing
if (this.queue.autoProcess) {
return this.queue._processNextBatch();
}
}
/**
* Adds a new task to this job's queue.
* @param {*} taskData - The data for the task (will be JSON serialized)
* @returns {number} The ID of the newly added task
* @throws {Error} When task insertion fails
* @fires Job#added
*/
add(taskData) {
try {
const taskId = this.queue.db.insertTask(
this.name,
JSON.stringify(taskData)
);
// Emit on this job instance
this.emit('added', { taskId, taskData });
// Bubble event up to queue with jobName
this.queue.emit('added', { jobName: this.name, taskId, taskData });
// If auto-processing is enabled and we have a handler
if (this.queue.autoProcess && this.handler) {
if (this.queue.isProcessing) {
// Mark that tasks were added during processing to trigger continuation
this.queue._tasksAddedDuringProcessing = true;
} else {
// Cancel any scheduled wake since we have immediate work now
this.queue.stopPolling();
setImmediate(() => this.queue._processNextBatch());
}
}
return taskId;
} catch (error) {
this.queue.emit('error', {
error,
operation: 'add',
jobName: this.name,
});
throw error;
}
}
/**
* Emits an event on this job instance and bubbles it up to the parent queue.
* @private
* @param {string} event - Event name
* @param {Object} data - Event data
*/
_emit(event, data) {
// Emit on this job instance
this.emit(event, data);
// Bubble event up to queue with jobName
this.queue.emit(event, { ...data, jobName: this.name });
}
}
/**
* Queue class for managing and processing background tasks.
* Extends EventEmitter to provide event-based notifications for task lifecycle events.
*
* @extends EventEmitter
* @fires Queue#added - When a task is added to the queue
* @fires Queue#completed - When a task completes successfully
* @fires Queue#failed - When a task fails after all retries
* @fires Queue#retried - When a task is scheduled for retry
* @fires Queue#error - When an error occurs during queue operations
*/
class Queue extends EventEmitter {
/**
* Creates a new Queue instance.
* @param {Object} [options={}] - Configuration options for the queue
* @param {string} [options.dbPath='./queue.db'] - Path to the SQLite database file
* @param {number} [options.maxConcurrent=5] - Maximum number of tasks to process concurrently
* @param {number} [options.maxRetries=15] - Maximum number of retry attempts for failed tasks
* @param {number} [options.baseRetryDelay=15_000] - Base delay in milliseconds between retries (exponential backoff)
* @param {number} [options.pollingInterval=5000] - Interval in milliseconds for polling new tasks
* @param {boolean} [options.autoProcess=true] - Whether to automatically process tasks when added
* @param {boolean} [options.jitter=true] - Whether to add randomness to retry delays
*/
constructor(options = {}) {
super();
this.dbPath = options.dbPath || './queue.db';
this.maxConcurrent = options.maxConcurrent || 5;
this.maxRetries = options.maxRetries || 15;
this.baseRetryDelay = options.baseRetryDelay || 15_000; // 15 seconds
this.pollingInterval = options.pollingInterval || 5000; // 5 seconds
this.autoProcess = options.autoProcess !== false; // defaults to true
this.jitter = options.jitter !== false; // adds randomness to retry delays
this.db = new Database(this.dbPath);
this.currentRunning = 0;
this.isProcessing = false;
this.jobs = new Map(); // Map of job name -> Job instance
this.handler = null; // Deprecated: kept for backward compatibility
this.pollingTimer = null; // used as a one-shot wake-up timer
this._tasksAddedDuringProcessing = false; // Flag to track if tasks were added while processing
}
/**
* Creates a new named job with its own processor function.
* @param {string} name - Name of the job type
* @returns {Job} A new Job instance
*/
createJob(name) {
if (this.jobs.has(name)) {
return this.jobs.get(name);
}
const job = new Job(this, name);
this.jobs.set(name, job);
return job;
}
/**
* Registers a job with the queue (internal method called by Job.process).
* @internal - This method is part of the internal API between Job and Queue
* @param {Job} job - The job instance to register
*/
_registerJob(job) {
if (!this.jobs.has(job.name)) {
this.jobs.set(job.name, job);
}
}
/**
* Adds a new task to the queue.
* @deprecated Use createJob(name).add(taskData) instead
* @param {*} taskData - The data for the task (will be JSON serialized)
* @returns {number} The ID of the newly added task
* @throws {Error} When task insertion fails
* @fires Queue#added
* @fires Queue#error
*/
add(taskData) {
try {
// Use a default job name for backward compatibility
const taskId = this.db.insertTask('default', JSON.stringify(taskData));
this.emit('added', { taskId, taskData });
// If auto-processing is enabled and we have a handler
if (this.autoProcess && this.handler) {
if (this.isProcessing) {
// Mark that tasks were added during processing to trigger continuation
this._tasksAddedDuringProcessing = true;
} else {
// Cancel any scheduled wake since we have immediate work now
this.stopPolling();
setImmediate(() => this._processNextBatch());
}
}
return taskId;
} catch (error) {
this.emit('error', { error, operation: 'add' });
throw error;
}
}
/**
* Sets up continuous task processing with the provided handler function.
* Starts polling for new tasks if autoProcess is enabled.
* @param {Function} handler - Function to process each task, receives task data as parameter
* @returns {Promise<void>} Promise that resolves after initial batch processing
* @throws {Error} When handler is not a function
*/
async process(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
this.handler = handler;
if (this.autoProcess) {
this._startPolling();
}
return this._processNextBatch();
}
/**
* Processes a single batch of tasks without setting up continuous processing.
* @param {Function} handler - Function to process each task, receives task data as parameter
* @returns {Promise<void>} Promise that resolves after batch processing completes
* @throws {Error} When handler is not a function
*/
async processOnce(handler) {
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
return this._processNextBatch(handler);
}
/**
* Processes the next batch of available tasks.
* @internal - This method is part of the internal API between Job and Queue
* @param {Function|null} [oneTimeHandler=null] - Optional one-time handler, otherwise uses instance handler
* @returns {Promise<void>} Promise that resolves after batch processing
* @throws {Error} When no handler is provided
* @fires Queue#error
*/
async _processNextBatch(oneTimeHandler = null) {
if (this.isProcessing && !oneTimeHandler) {
return; // Already processing
}
const handlerToUse = oneTimeHandler || this.handler;
// Check if we have any jobs with handlers or a legacy handler
const hasJobHandlers = Array.from(this.jobs.values()).some(
(job) => job.handler
);
if (!handlerToUse && !hasJobHandlers) {
// No handlers available, nothing to process
return;
}
this.isProcessing = true;
// Reset the flag at the start of each batch
this._tasksAddedDuringProcessing = false;
try {
const availableSlots = this.maxConcurrent - this.currentRunning;
if (availableSlots <= 0) {
return;
}
const now = new Date().toISOString();
// Get job names that have handlers registered
const jobNamesWithHandlers = Array.from(this.jobs.entries())
.filter(([, job]) => job.handler)
.map(([name]) => name);
// Fetch tasks for jobs with handlers, or all tasks if using legacy handler
const tasks = handlerToUse
? this.db.getPendingTasks(availableSlots, now)
: this.db.getPendingTasks(availableSlots, now, jobNamesWithHandlers);
const processingPromises = tasks.map((task) =>
this._processTask(task, handlerToUse)
);
await Promise.all(processingPromises);
// If there might be more tasks, process them
if (
tasks.length === availableSlots &&
this.currentRunning < this.maxConcurrent
) {
setImmediate(() => this._processNextBatch(oneTimeHandler));
}
} catch (error) {
this.emit('error', { error, operation: 'process' });
} finally {
if (!oneTimeHandler) {
this.isProcessing = false;
// Check if tasks were added during processing and continue immediately
if (this._tasksAddedDuringProcessing) {
this._tasksAddedDuringProcessing = false;
setImmediate(() => this._processNextBatch());
} else {
// No new tasks added, schedule next wake based on retry times
this._scheduleNextWake();
}
}
}
}
/**
* Processes a single task with the provided handler.
* @private
* @param {Object} task - The task object from the database
* @param {Function} legacyHandler - Optional legacy handler function (for backward compatibility)
* @returns {Promise<void>} Promise that resolves after task processing
* @fires Queue#completed
*/
async _processTask(task, legacyHandler = null) {
this.currentRunning++;
try {
this.db.updateTaskStatus(task.id, 'processing', task.retry_count, null);
let taskData;
try {
taskData = JSON.parse(task.task_data);
} catch (parseError) {
throw new Error(`Invalid task data JSON: ${parseError.message}`);
}
// Determine which handler to use
let handler;
let job = null;
if (legacyHandler) {
// Use legacy handler for backward compatibility
handler = legacyHandler;
} else {
// Find the job for this task
job = this.jobs.get(task.job_name);
if (!job || !job.handler) {
throw new Error(`No handler registered for job: ${task.job_name}`);
}
handler = job.handler;
}
const result = await handler(taskData);
this.db.updateTaskStatus(task.id, 'completed', task.retry_count, null);
// Emit through job instance if available, which will bubble up to queue
if (job) {
job._emit('completed', { taskId: task.id, result, taskData });
} else {
// Legacy: emit directly on queue
this.emit('completed', { taskId: task.id, result, taskData });
}
} catch (error) {
await this._handleTaskFailure(task, error, legacyHandler);
} finally {
this.currentRunning--;
}
}
/**
* Handles task failure by implementing retry logic with exponential backoff.
* @private
* @param {Object} task - The failed task object
* @param {Error} error - The error that caused the task to fail
* @param {Function} legacyHandler - Optional legacy handler (for backward compatibility)
* @returns {Promise<void>} Promise that resolves after handling the failure
* @fires Queue#retried
* @fires Queue#failed
*/
async _handleTaskFailure(task, error, legacyHandler = null) {
const retryCount = task.retry_count + 1;
const job = this.jobs.get(task.job_name);
if (retryCount <= this.maxRetries) {
const baseDelay = this.baseRetryDelay * Math.pow(2, retryCount - 1);
const jitterDelay = this.jitter
? baseDelay * (0.5 + Math.random() * 0.5)
: baseDelay;
const delay = Math.floor(jitterDelay);
const nextRetryAt = new Date(Date.now() + delay).toISOString();
this.db.updateTaskStatus(task.id, 'failed', retryCount, nextRetryAt);
let taskData;
try {
taskData = JSON.parse(task.task_data);
} catch (parseError) {
console.error('Error parsing task data:', parseError);
taskData = { raw: task.task_data };
}
const eventData = {
taskId: task.id,
taskData,
retryCount,
nextRetryAt,
delay,
error: error.message,
};
// Emit through job instance if available, which will bubble up to queue
if (job && !legacyHandler) {
job._emit('retried', eventData);
} else {
// Legacy: emit directly on queue
this.emit('retried', eventData);
}
// Ensure a wake-up is scheduled for future processing
this._scheduleNextWake();
} else {
this.db.updateTaskStatus(task.id, 'failed', retryCount, null);
let taskData;
try {
taskData = JSON.parse(task.task_data);
} catch (parseError) {
console.error('Error parsing task data:', parseError);
taskData = { raw: task.task_data };
}
const eventData = {
taskId: task.id,
error: error.message,
taskData,
retryCount,
};
// Emit through job instance if available, which will bubble up to queue
if (job && !legacyHandler) {
job._emit('failed', eventData);
} else {
// Legacy: emit directly on queue
this.emit('failed', eventData);
}
}
}
/**
* Starts polling for new tasks at regular intervals.
* @private
* @returns {void}
*/
_startPolling() {
// Start by scheduling an immediate wake to process any ready tasks now
const hasAnyHandler =
this.handler || Array.from(this.jobs.values()).some((job) => job.handler);
if (!this.isProcessing && hasAnyHandler) {
setImmediate(() => this._processNextBatch());
}
// After that, rely on one-shot wake scheduling instead of a persistent interval
this._scheduleNextWake();
}
/**
* Stops the polling timer for new tasks.
* @returns {void}
*/
stopPolling() {
if (this.pollingTimer) {
clearTimeout(this.pollingTimer);
this.pollingTimer = null;
}
}
/**
* Schedule a one-shot wake-up based on the earliest next_retry_at.
* Uses unref() so it won't keep the process alive when idle.
* If there are no scheduled retries, no timer is set.
* @private
*/
_scheduleNextWake() {
// Clear any existing timer first
this.stopPolling();
// Nothing to schedule if we are not auto-processing or have no handlers
const hasAnyHandler =
this.handler || Array.from(this.jobs.values()).some((job) => job.handler);
if (!this.autoProcess || !hasAnyHandler) {
return;
}
// If there are currently tasks running or we're processing, no need to schedule
if (this.isProcessing || this.currentRunning > 0) {
return;
}
const earliest = this.db.getEarliestNextRetryTime();
// No scheduled retries, remain idle. New tasks will wake via add().
if (!earliest) {
return;
}
const delay = Math.max(0, new Date(earliest).getTime() - Date.now());
const timer = setTimeout(() => {
// Guard: handler might have been removed/stopped
const hasHandler =
this.handler ||
Array.from(this.jobs.values()).some((job) => job.handler);
if (!this.isProcessing && hasHandler) {
this._processNextBatch();
}
}, delay);
// Do not keep the process alive while waiting
if (typeof timer.unref === 'function') {
timer.unref();
}
this.pollingTimer = timer;
}
/**
* Retrieves statistics about tasks grouped by their status.
* @returns {Array<Object>} Array of objects with status and count properties
*/
getStats() {
return this.db.getTaskStats();
}
/**
* Retrieves a specific task by its ID.
* @param {number} id - The ID of the task to retrieve
* @returns {Object|undefined} The task object if found, undefined otherwise
*/
getTask(id) {
return this.db.getTaskById(id);
}
/**
* Deletes completed tasks older than the specified time period.
* @param {number} [olderThanHours=24] - Tasks older than this many hours will be deleted
* @returns {Object} Result object with changes count indicating how many tasks were deleted
*/
cleanup(olderThanHours = 24) {
return this.db.cleanupCompletedTasks(olderThanHours);
}
/**
* Gracefully closes the queue by stopping polling and waiting for running tasks to complete.
* @returns {Promise<void>} Promise that resolves when the queue is fully closed
*/
async close() {
this.stopPolling();
// Wait for current tasks to finish
while (this.currentRunning > 0) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return this.db.close();
}
/**
* Gets the current status of the queue.
* @returns {Object} Status object with currentRunning, maxConcurrent, isProcessing, autoProcess, hasHandler, and jobs properties
*/
get status() {
const jobsStatus = {};
for (const [name, job] of this.jobs.entries()) {
jobsStatus[name] = {
hasHandler: !!job.handler,
};
}
return {
currentRunning: this.currentRunning,
maxConcurrent: this.maxConcurrent,
isProcessing: this.isProcessing,
autoProcess: this.autoProcess,
hasHandler: !!this.handler,
jobs: jobsStatus,
};
}
}
export default Queue;
export { Job };