function normalizeText(value) { return String(value || '').trim(); } function toIsoDate(value) { if (!value) { return ''; } const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toISOString(); } function normalizeIntervalMs(value, unit) { const numericValue = Math.max(1, Number(value) || 0); const normalizedUnit = String(unit || 'minutes').trim().toLowerCase(); if (normalizedUnit === 'seconds') { return numericValue * 1000; } return numericValue * 60 * 1000; } function createBackgroundTaskQueue(options) { const pool = options && options.pool; const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1); const taskHandlers = new Map(); const tasksById = new Map(); const recurringJobsByKey = new Map(); const taskIdToRecurringKey = new Map(); const pendingIds = []; let nextTaskId = 1; let activeCount = 0; let drainScheduled = false; let initializationPromise = null; function createTaskCompletionController() { let resolveCompletion = null; let rejectCompletion = null; const completionPromise = new Promise(function (resolve, reject) { resolveCompletion = resolve; rejectCompletion = reject; }); return { promise: completionPromise, resolve: resolveCompletion, reject: rejectCompletion }; } function parseJsonValue(value, fallback) { if (value === null || value === undefined || value === '') { return fallback; } if (typeof value === 'object') { return value; } try { return JSON.parse(String(value)); } catch (_error) { return fallback; } } function stringifyJsonValue(value) { if (value === undefined || value === null) { return null; } return JSON.stringify(value); } function buildSnapshot(task) { return { id: task.id, key: task.key, taskType: task.taskType || '', title: task.title, category: task.category, status: task.status, createdAt: task.createdAt, startedAt: task.startedAt, finishedAt: task.finishedAt, errorMessage: task.errorMessage, attempts: task.attempts || 0, metadata: task.metadata, payload: task.payload || null }; } function getTaskById(taskId) { const numericTaskId = Number(taskId); if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) { return null; } return tasksById.get(numericTaskId) || null; } function buildTaskFromRow(row) { const task = { id: Number(row.id), key: String(row.task_key || '').trim(), taskType: String(row.task_type || '').trim(), title: String(row.title || 'Background task').trim() || 'Background task', category: String(row.category || 'general').trim() || 'general', status: String(row.status || 'queued').trim() || 'queued', createdAt: row.created_at ? new Date(row.created_at).toISOString() : '', startedAt: row.started_at ? new Date(row.started_at).toISOString() : '', finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '', errorMessage: String(row.error_message || ''), attempts: Math.max(0, Number(row.attempts) || 0), metadata: parseJsonValue(row.metadata_json, {}), payload: parseJsonValue(row.payload_json, null), completionPromise: null, resolveCompletion: null, rejectCompletion: null, persisted: true, run: typeof row.run === 'function' ? row.run : function () { return Promise.resolve(); } }; const completionController = createTaskCompletionController(); task.completionPromise = completionController.promise; task.resolveCompletion = completionController.resolve; task.rejectCompletion = completionController.reject; return task; } function buildTaskRecord(task) { return { task_key: task.key || null, task_type: task.taskType || 'general', title: task.title, category: task.category || 'general', status: task.status, payload_json: stringifyJsonValue(task.payload), metadata_json: stringifyJsonValue(task.metadata), attempts: Math.max(0, Number(task.attempts) || 0), created_at: task.createdAt ? new Date(task.createdAt) : new Date(), started_at: task.startedAt ? new Date(task.startedAt) : null, finished_at: task.finishedAt ? new Date(task.finishedAt) : null, error_message: task.errorMessage || null }; } async function persistTaskInsert(task) { if (!pool || !task.taskType) { return task; } const record = buildTaskRecord(task); const [result] = await pool.query( `INSERT INTO background_tasks ( task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ record.task_key, record.task_type, record.title, record.category, record.status, record.payload_json, record.metadata_json, record.attempts, record.created_at, record.started_at, record.finished_at, record.error_message ] ); task.id = Number(result.insertId); task.persisted = true; return task; } async function persistTaskUpdate(task) { if (!pool || !task.persisted) { return; } const record = buildTaskRecord(task); await pool.query( `UPDATE background_tasks SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ? WHERE id = ?`, [ record.task_key, record.task_type, record.title, record.category, record.status, record.payload_json, record.metadata_json, record.attempts, record.started_at, record.finished_at, record.error_message, task.id ] ); } async function persistTaskDelete(taskId) { if (!pool) { return; } await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]); } async function initialize() { if (initializationPromise) { return initializationPromise; } initializationPromise = (async function () { if (!pool) { return; } const [rows] = await pool.query( 'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC' ); let highestTaskId = 0; for (const row of rows || []) { const task = buildTaskFromRow(row); if (!Number.isInteger(task.id) || task.id <= 0) { continue; } highestTaskId = Math.max(highestTaskId, task.id); tasksById.set(task.id, task); if (task.status === 'running') { task.status = 'queued'; task.startedAt = ''; task.finishedAt = ''; task.errorMessage = ''; await pool.query( 'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?', ['queued', task.id] ); } if (task.status === 'queued' || task.status === 'running') { pendingIds.push(task.id); } } nextTaskId = Math.max(nextTaskId, highestTaskId + 1); if (pendingIds.length) { scheduleDrain(); } })(); return initializationPromise; } function scheduleDrain() { if (drainScheduled) { return; } drainScheduled = true; setTimeout(function () { drainScheduled = false; processQueue(); }, 0); } function clearRecurringTimer(job) { if (job && job.timerId) { clearTimeout(job.timerId); job.timerId = null; } } function setTaskHandler(taskType, handler) { const normalizedTaskType = normalizeText(taskType); if (!normalizedTaskType || typeof handler !== 'function') { return false; } taskHandlers.set(normalizedTaskType, handler); return true; } function scheduleRecurringRun(job, delayMs) { if (!job || job.enabled === false) { return; } clearRecurringTimer(job); const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0); job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay)); job.timerId = setTimeout(function () { job.timerId = null; triggerRecurringJob(job.key); }, safeDelay); } function triggerRecurringJob(recurringKey) { const job = recurringJobsByKey.get(recurringKey); if (!job || job.enabled === false) { return; } if (job.activeTaskId && tasksById.has(job.activeTaskId)) { scheduleRecurringRun(job, job.intervalMs); return; } job.activeTaskId = -1; enqueueTask({ key: `${job.key}:${Date.now()}`, title: job.title, category: job.category, taskType: job.taskType || '', metadata: Object.assign({}, job.metadata || {}, { recurringKey: job.key, recurringTitle: job.title }), payload: Object.assign({}, job.payload || {}, { recurringKey: job.key }), run: job.run, persist: Boolean(job.taskType) }).then(function (task) { if (task && Number.isInteger(task.id)) { job.activeTaskId = task.id; taskIdToRecurringKey.set(task.id, job.key); } else { job.activeTaskId = null; } }).catch(function (error) { job.activeTaskId = null; console.error(error); }); scheduleRecurringRun(job, job.intervalMs); } function syncRecurringTaskState(task, status, errorMessage) { const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey); if (!recurringKey) { return; } const job = recurringJobsByKey.get(recurringKey); if (!job) { taskIdToRecurringKey.delete(task.id); return; } job.activeTaskId = null; job.lastRunAt = toIsoDate(new Date()); job.lastStatus = status; job.lastError = errorMessage ? String(errorMessage) : ''; taskIdToRecurringKey.delete(task.id); } async function processQueue() { while (activeCount < maxConcurrent) { const nextTaskId = pendingIds.shift(); if (!nextTaskId) { break; } const task = tasksById.get(nextTaskId); if (!task || task.status !== 'queued') { continue; } activeCount += 1; task.status = 'running'; task.startedAt = toIsoDate(new Date()); task.errorMessage = ''; task.attempts = Math.max(0, Number(task.attempts) || 0) + 1; try { await persistTaskUpdate(task); } catch (error) { task.status = 'failed'; task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.'); task.finishedAt = toIsoDate(new Date()); if (typeof task.rejectCompletion === 'function') { const completionError = new Error(task.errorMessage); completionError.task = buildSnapshot(task); task.rejectCompletion(completionError); } activeCount = Math.max(0, activeCount - 1); scheduleDrain(); continue; } Promise.resolve() .then(function () { if (task.taskType) { const handler = taskHandlers.get(task.taskType); if (!handler) { throw new Error('No handler registered for task type ' + task.taskType + '.'); } return handler({ id: task.id, key: task.key, title: task.title, category: task.category, taskType: task.taskType, payload: task.payload, metadata: task.metadata, attempts: task.attempts }); } return task.run({ id: task.id, key: task.key, title: task.title, category: task.category, metadata: task.metadata }); }) .then(function () { task.status = 'completed'; task.finishedAt = toIsoDate(new Date()); if (typeof task.resolveCompletion === 'function') { task.resolveCompletion(buildSnapshot(task)); } syncRecurringTaskState(task, task.status, ''); return persistTaskUpdate(task); }) .catch(function (error) { task.status = 'failed'; task.errorMessage = String(error && error.message ? error.message : 'Background task failed.'); task.finishedAt = toIsoDate(new Date()); if (typeof task.rejectCompletion === 'function') { const completionError = new Error(task.errorMessage); completionError.task = buildSnapshot(task); task.rejectCompletion(completionError); } syncRecurringTaskState(task, task.status, task.errorMessage); return persistTaskUpdate(task); }) .finally(function () { activeCount = Math.max(0, activeCount - 1); scheduleDrain(); }); } } async function enqueueTask(definition) { const normalizedKey = normalizeText(definition && definition.key); const normalizedTitle = normalizeText(definition && definition.title) || 'Background task'; const normalizedTaskType = normalizeText(definition && definition.taskType); const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType)); const existingTask = normalizedKey ? Array.from(tasksById.values()).find(function (task) { return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType); }) : null; if (existingTask) { existingTask.title = normalizedTitle; existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general'; existingTask.metadata = definition && definition.metadata ? definition.metadata : {}; existingTask.taskType = normalizedTaskType || existingTask.taskType || ''; existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload; existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run; existingTask.createdAt = toIsoDate(new Date()); existingTask.errorMessage = ''; existingTask.persisted = existingTask.persisted || shouldPersist; await persistTaskUpdate(existingTask); return buildSnapshot(existingTask); } const task = { id: shouldPersist ? 0 : nextTaskId, key: normalizedKey, taskType: normalizedTaskType, title: normalizedTitle, category: normalizeText(definition && definition.category) || 'general', status: 'queued', createdAt: toIsoDate(new Date()), startedAt: '', finishedAt: '', errorMessage: '', attempts: 0, metadata: definition && definition.metadata ? definition.metadata : {}, payload: definition && definition.payload !== undefined ? definition.payload : null, persisted: shouldPersist, run: typeof definition.run === 'function' ? definition.run : function () { return Promise.resolve(); }, completionPromise: null, resolveCompletion: null, rejectCompletion: null }; const completionController = createTaskCompletionController(); task.completionPromise = completionController.promise; task.resolveCompletion = completionController.resolve; task.rejectCompletion = completionController.reject; if (shouldPersist) { await persistTaskInsert(task); } else { nextTaskId += 1; } tasksById.set(task.id, task); pendingIds.push(task.id); scheduleDrain(); return buildSnapshot(task); } function registerRecurringTask(definition) { const normalizedKey = normalizeText(definition && definition.key); if (!normalizedKey) { throw new Error('Recurring tasks require a key.'); } const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0); if (!Number.isFinite(intervalMs) || intervalMs < 1000) { throw new Error('Recurring tasks require a valid interval.'); } const job = recurringJobsByKey.get(normalizedKey) || { key: normalizedKey, activeTaskId: null, lastRunAt: '', lastStatus: '', lastError: '', nextRunAt: '', timerId: null, enabled: true }; clearRecurringTimer(job); job.title = normalizeText(definition && definition.title) || 'Background task'; job.category = normalizeText(definition && definition.category) || 'general'; job.intervalMs = intervalMs; job.metadata = definition && definition.metadata ? definition.metadata : {}; job.run = typeof definition.run === 'function' ? definition.run : function () { return Promise.resolve(); }; job.enabled = definition && definition.enabled === false ? false : true; recurringJobsByKey.set(normalizedKey, job); if (job.enabled) { scheduleRecurringRun(job, intervalMs); } return buildRecurringSnapshot(job); } function removeRecurringTask(recurringKey) { const normalizedKey = normalizeText(recurringKey); const job = recurringJobsByKey.get(normalizedKey); if (!job) { return false; } clearRecurringTimer(job); recurringJobsByKey.delete(normalizedKey); return true; } function buildRecurringSnapshot(job) { return { key: job.key, title: job.title, category: job.category, intervalMs: job.intervalMs, enabled: job.enabled !== false, activeTaskId: job.activeTaskId || null, createdAt: job.createdAt || '', nextRunAt: job.nextRunAt || '', lastRunAt: job.lastRunAt || '', lastStatus: job.lastStatus || '', lastError: job.lastError || '', metadata: job.metadata || {} }; } function listTasks() { return Array.from(tasksById.values()) .slice() .sort(function (left, right) { const statusRank = { running: 0, queued: 1, failed: 2, completed: 3, canceled: 4 }; const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9; const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9; if (leftRank !== rightRank) { return leftRank - rightRank; } return right.id - left.id; }) .map(buildSnapshot); } function listRecurringTasks() { return Array.from(recurringJobsByKey.values()) .slice() .sort(function (left, right) { return left.key.localeCompare(right.key); }) .map(buildRecurringSnapshot); } function clearFinishedTasks() { let removedCount = 0; Array.from(tasksById.values()).forEach(function (task) { if (task.status === 'running' || task.status === 'queued') { return; } tasksById.delete(task.id); removedCount += 1; persistTaskDelete(task.id).catch(function (error) { console.warn('Unable to delete finished task from persistence:', error); }); }); return removedCount; } function cancelTask(taskId) { const task = getTaskById(taskId); if (!task || task.status !== 'queued') { return false; } task.status = 'canceled'; task.finishedAt = toIsoDate(new Date()); const pendingIndex = pendingIds.indexOf(task.id); if (pendingIndex >= 0) { pendingIds.splice(pendingIndex, 1); } if (typeof task.rejectCompletion === 'function') { const cancellationError = new Error('Task canceled.'); cancellationError.task = buildSnapshot(task); task.rejectCompletion(cancellationError); } persistTaskUpdate(task).catch(function (error) { console.warn('Unable to persist canceled task:', error); }); return true; } function retryTask(taskId) { const task = getTaskById(taskId); if (!task || task.status !== 'failed') { return null; } return enqueueTask({ key: task.key, title: task.title, category: task.category, metadata: task.metadata, run: task.run }); } function getSummary() { const counts = { queued: 0, running: 0, completed: 0, failed: 0, canceled: 0 }; listTasks().forEach(function (task) { if (Object.prototype.hasOwnProperty.call(counts, task.status)) { counts[task.status] += 1; } }); return { activeCount: activeCount, counts: counts, scheduledCount: recurringJobsByKey.size, total: listTasks().length }; } return { enqueueTask: enqueueTask, enqueueTaskAndWait: function (definition) { return enqueueTask(definition).then(function (snapshot) { const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null; if (!task || !task.completionPromise) { return snapshot; } return task.completionPromise; }); }, initialize: initialize, setTaskHandler: setTaskHandler, registerRecurringTask: registerRecurringTask, removeRecurringTask: removeRecurringTask, listTasks: listTasks, listRecurringTasks: listRecurringTasks, getTaskById: getTaskById, getSummary: getSummary, cancelTask: cancelTask, retryTask: retryTask, clearFinishedTasks: clearFinishedTasks }; } module.exports = { createBackgroundTaskQueue: createBackgroundTaskQueue, normalizeIntervalMs: normalizeIntervalMs };