Release v2.2.0
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// Shared loader for task folders under background-tasks.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function getTaskExport(taskModule) {
|
||||
return typeof taskModule === 'function'
|
||||
? taskModule
|
||||
: taskModule && typeof taskModule === 'object'
|
||||
? Object.values(taskModule).find(function (value) {
|
||||
return typeof value === 'function';
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
function loadTaskModules(directory, options) {
|
||||
const taskFiles = fs.readdirSync(directory)
|
||||
.filter(function (fileName) {
|
||||
return fileName.toLowerCase().endsWith('.js') && fileName !== 'index.js';
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return Promise.all(taskFiles.map(function (fileName) {
|
||||
const taskModule = require(path.join(directory, fileName));
|
||||
const exported = getTaskExport(taskModule);
|
||||
|
||||
if (typeof exported === 'function') {
|
||||
return exported(options);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
// Register ad hoc task handlers that execute queued background work.
|
||||
function registerAdhocTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-adhoc');
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.captureSlideThumbnail || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerAdhocTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
// Register one-time startup work that should run during boot only.
|
||||
function registerStartupTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-startup');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerStartupTasks requires the startup task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
// Register recurring maintenance and data-source refresh tasks.
|
||||
function registerScheduledTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-scheduled');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerScheduledTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeBackgroundTasks(options) {
|
||||
registerAdhocTasks(options);
|
||||
|
||||
const backgroundTaskStartup = registerStartupTasks(options);
|
||||
if (backgroundTaskStartup && typeof backgroundTaskStartup.initialize === 'function') {
|
||||
await backgroundTaskStartup.initialize();
|
||||
}
|
||||
|
||||
const backgroundTaskScheduling = registerScheduledTasks(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAdhocTasks: registerAdhocTasks,
|
||||
registerStartupTasks: registerStartupTasks,
|
||||
registerScheduledTasks: registerScheduledTasks,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
// Database-backed background task queue.
|
||||
|
||||
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 recurringJobsByKey = new Map();
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('createBackgroundTaskQueue requires a database pool.');
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: Number(task && task.id) || 0,
|
||||
key: String(task && task.key || '').trim(),
|
||||
taskType: String(task && task.taskType || '').trim(),
|
||||
title: String(task && task.title || 'Background task').trim() || 'Background task',
|
||||
category: String(task && task.category || 'general').trim() || 'general',
|
||||
status: String(task && task.status || 'queued').trim() || 'queued',
|
||||
createdAt: String(task && task.createdAt || ''),
|
||||
startedAt: String(task && task.startedAt || ''),
|
||||
finishedAt: String(task && task.finishedAt || ''),
|
||||
errorMessage: String(task && task.errorMessage || ''),
|
||||
attempts: Math.max(0, Number(task && task.attempts) || 0),
|
||||
metadata: task && task.metadata ? task.metadata : {},
|
||||
payload: task && task.payload !== undefined ? task.payload : null
|
||||
};
|
||||
}
|
||||
|
||||
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 hydrateTaskRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || null,
|
||||
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) {
|
||||
const normalizedTaskType = normalizeText(task && task.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(Object.assign({}, task, {
|
||||
taskType: normalizedTaskType
|
||||
}));
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_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);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!task || !Number.isInteger(Number(task.id)) || Number(task.id) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_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,
|
||||
Number(task.id)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [numericTaskId]);
|
||||
}
|
||||
|
||||
async function fetchTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 o_background_tasks
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[numericTaskId]
|
||||
);
|
||||
|
||||
return hydrateTaskRow(rows && rows[0]) || null;
|
||||
}
|
||||
|
||||
async function fetchQueuedTaskCount() {
|
||||
const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM o_background_tasks WHERE status = 'queued'`);
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function claimNextTask() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [rows] = await connection.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
|
||||
const row = rows && rows[0] ? rows[0] : null;
|
||||
if (!row) {
|
||||
await connection.commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextAttempts = Math.max(0, Number(row.attempts) || 0) + 1;
|
||||
await connection.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'running', started_at = NOW(), attempts = ?, error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[nextAttempts, row.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
return hydrateTaskRow(Object.assign({}, row, {
|
||||
status: 'running',
|
||||
started_at: new Date(),
|
||||
attempts: nextAttempts,
|
||||
error_message: null
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = String(status || '');
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
setTaskHandler('recurring-run', async function (task) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
throw new Error('Recurring task key is required.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || typeof job.run !== 'function') {
|
||||
throw new Error('Recurring task not found.');
|
||||
}
|
||||
|
||||
return job.run(task);
|
||||
});
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'queued', started_at = NULL, finished_at = NULL, error_message = NULL
|
||||
WHERE status = 'running'`
|
||||
);
|
||||
|
||||
if (await fetchQueuedTaskCount() > 0) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const task = await claimNextTask();
|
||||
if (!task) {
|
||||
break;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
await handler(task);
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
await persistTaskUpdate(task);
|
||||
await syncRecurringTaskState(task, task.status, '');
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (persistError) {
|
||||
console.error(persistError);
|
||||
}
|
||||
await syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
}
|
||||
})().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: 0,
|
||||
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
|
||||
};
|
||||
|
||||
await persistTaskInsert(task);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
async function enqueueTaskAndWait(definition) {
|
||||
const snapshot = await enqueueTask(definition);
|
||||
const taskId = snapshot && snapshot.id ? snapshot.id : null;
|
||||
if (!taskId) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const currentTask = await fetchTaskById(taskId);
|
||||
if (!currentTask) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (currentTask.status !== 'queued' && currentTask.status !== 'running') {
|
||||
return buildSnapshot(currentTask);
|
||||
}
|
||||
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
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).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
async function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: 'recurring-run',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
})
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return triggerRecurringJob(normalizedKey);
|
||||
}
|
||||
|
||||
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,
|
||||
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.payload = definition && definition.payload ? definition.payload : null;
|
||||
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, state) {
|
||||
const latestState = state || null;
|
||||
const activeTaskId = latestState && (latestState.status === 'queued' || latestState.status === 'running')
|
||||
? latestState.id
|
||||
: null;
|
||||
const lastRunAt = latestState
|
||||
? latestState.finishedAt || latestState.startedAt || latestState.createdAt || ''
|
||||
: job.lastRunAt || '';
|
||||
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: activeTaskId,
|
||||
createdAt: latestState ? latestState.createdAt || '' : job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: lastRunAt || '',
|
||||
lastStatus: latestState ? latestState.status || '' : job.lastStatus || '',
|
||||
lastError: latestState ? latestState.errorMessage || '' : job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecurringTaskStates() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, status, created_at, started_at, finished_at, error_message, metadata_json
|
||||
FROM o_background_tasks
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.recurringKey')) IS NOT NULL
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
const statesByKey = new Map();
|
||||
for (const row of rows || []) {
|
||||
const metadata = parseJsonValue(row.metadata_json, {});
|
||||
const recurringKey = normalizeText(metadata && metadata.recurringKey);
|
||||
if (!recurringKey || statesByKey.has(recurringKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
statesByKey.set(recurringKey, {
|
||||
id: Number(row.id),
|
||||
status: String(row.status || ''),
|
||||
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 || '')
|
||||
});
|
||||
}
|
||||
|
||||
return statesByKey;
|
||||
}
|
||||
|
||||
async function listTasks() {
|
||||
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 o_background_tasks
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'running' THEN 0
|
||||
WHEN 'queued' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'canceled' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC`
|
||||
);
|
||||
|
||||
return (rows || []).map(hydrateTaskRow);
|
||||
}
|
||||
|
||||
async function listRecurringTasks() {
|
||||
const statesByKey = await fetchRecurringTaskStates();
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(function (job) {
|
||||
return buildRecurringSnapshot(job, statesByKey.get(job.key) || null);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearFinishedTasks() {
|
||||
const [result] = await pool.query(
|
||||
`DELETE FROM o_background_tasks
|
||||
WHERE status IN ('completed', 'failed', 'canceled')`
|
||||
);
|
||||
return Number(result && result.affectedRows) || 0;
|
||||
}
|
||||
|
||||
async function cancelTask(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'canceled',
|
||||
finished_at = NOW(),
|
||||
error_message = 'Task canceled.'
|
||||
WHERE id = ?
|
||||
AND status = 'queued'`,
|
||||
[numericTaskId]
|
||||
);
|
||||
return Number(result && result.affectedRows) > 0;
|
||||
}
|
||||
|
||||
async function retryTask(taskId) {
|
||||
const task = await fetchTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload
|
||||
});
|
||||
}
|
||||
|
||||
async function getSummary() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
SUM(status = 'queued') AS queued,
|
||||
SUM(status = 'running') AS running,
|
||||
SUM(status = 'completed') AS completed,
|
||||
SUM(status = 'failed') AS failed,
|
||||
SUM(status = 'canceled') AS canceled,
|
||||
COUNT(*) AS total
|
||||
FROM o_background_tasks`
|
||||
);
|
||||
const row = rows && rows[0] ? rows[0] : {};
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: {
|
||||
queued: Number(row.queued) || 0,
|
||||
running: Number(row.running) || 0,
|
||||
completed: Number(row.completed) || 0,
|
||||
failed: Number(row.failed) || 0,
|
||||
canceled: Number(row.canceled) || 0
|
||||
},
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: Number(row.total) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: enqueueTaskAndWait,
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: fetchTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
};
|
||||
|
||||
function registerDataSourceRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('registerDataSourceRefreshTask requires the data source refresh dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
// Resolve the source record at execution time so stale queued tasks fail cleanly.
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDataSourceRefreshTask };
|
||||
@@ -0,0 +1,39 @@
|
||||
function registerFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !uploadSyncService) {
|
||||
throw new Error('registerFontSyncTask requires the font sync dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : task || {};
|
||||
const uploadDir = String(payload.uploadDir || '').trim();
|
||||
const operations = Array.isArray(payload.operations)
|
||||
? payload.operations
|
||||
: Array.isArray(payload.uploadPaths)
|
||||
? payload.uploadPaths.map(function (uploadPath) {
|
||||
return { type: String(payload.action || 'put').trim().toLowerCase(), uploadPath: uploadPath };
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!uploadDir || !operations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
const operation = operations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
|
||||
} else {
|
||||
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSyncTask };
|
||||
@@ -0,0 +1,20 @@
|
||||
const TASK = {
|
||||
taskType: 'media-sync'
|
||||
};
|
||||
|
||||
function registerMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const runMediaSyncTask = uploadSyncService && uploadSyncService.runMediaSyncTask;
|
||||
|
||||
if (!backgroundTaskQueue || typeof runMediaSyncTask !== 'function') {
|
||||
throw new Error('registerMediaSyncTask requires the media sync dependencies.');
|
||||
}
|
||||
|
||||
// Media sync is just a pass-through to the shared media sync runner.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerMediaSyncTask };
|
||||
@@ -0,0 +1,51 @@
|
||||
const TASK = {
|
||||
taskType: 'slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Refresh the slide thumbnail for one slide id.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,65 @@
|
||||
const TASK = {
|
||||
taskType: 'template-slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Walk every slide in the template and regenerate its thumbnail.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerTemplateSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,139 @@
|
||||
const TASK = {
|
||||
key: 'recurring-data-source-refreshes',
|
||||
title: 'Recurring data source refreshes',
|
||||
category: 'data-source',
|
||||
trigger: 'scheduled recurring task definitions from the database',
|
||||
purpose: 'keep API sources and RSS feeds refreshed on their configured intervals.',
|
||||
taskType: 'data-source-refresh',
|
||||
intervalMs: null
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('registerRecurringDataSourceRefreshes requires the recurring refresh dependencies.');
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function createDataSourceTaskService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('createDataSourceTaskService requires the data source task dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
async function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = await backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRecurringKey: formatRecurringKey,
|
||||
buildRecurringTitle: buildRecurringTitle,
|
||||
registerRecurringRefresh: registerRecurringRefresh,
|
||||
removeRecurringRefresh: removeRecurringRefresh,
|
||||
getTaskStatusById: getTaskStatusById,
|
||||
refreshApiSourceInBackground: refreshApiSourceInBackground,
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerRecurringDataSourceRefreshes: registerRecurringDataSourceRefreshes,
|
||||
createDataSourceTaskService: createDataSourceTaskService
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const TASK = {
|
||||
key: 'font-sweep',
|
||||
title: 'Font sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'reconcile managed fonts on the player and remove stale font files.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const {
|
||||
collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations
|
||||
} = require('../../media/font-library');
|
||||
|
||||
function registerFontSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSweepTask };
|
||||
@@ -0,0 +1,42 @@
|
||||
const TASK = {
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'remove uploaded media files that are no longer referenced.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
function registerUnusedUploadSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
|
||||
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerUnusedUploadSweepTask };
|
||||
@@ -0,0 +1,76 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
category: 'data-source',
|
||||
};
|
||||
|
||||
function scheduleStartupDataSourceRefreshes(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('scheduleStartupDataSourceRefreshes requires the startup refresh dependencies.');
|
||||
}
|
||||
|
||||
const staggerMs = Math.max(100, Number(dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
function buildStartupSource(type, id, name, run) {
|
||||
return {
|
||||
type: type,
|
||||
id: Number(id),
|
||||
name: name,
|
||||
run: run
|
||||
};
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}));
|
||||
});
|
||||
|
||||
// Stagger startup refreshes to avoid a burst against the DB/player.
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * staggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: TASK.category,
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = { scheduleStartupDataSourceRefreshes };
|
||||
@@ -0,0 +1,32 @@
|
||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||
|
||||
const TASK = {
|
||||
key: 'initial-font-sync',
|
||||
category: 'fonts'
|
||||
};
|
||||
|
||||
function registerInitialFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial font sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialFontSyncTask };
|
||||
@@ -0,0 +1,29 @@
|
||||
const TASK = {
|
||||
key: 'initial-media-sync',
|
||||
category: 'media-sync',
|
||||
};
|
||||
|
||||
function registerInitialMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial media sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialMediaSyncTask };
|
||||
Reference in New Issue
Block a user