alchemymvc
Version:
MVC framework for Node.js
958 lines (783 loc) • 22.3 kB
JavaScript
let TIMEOUT_UPDATE_INTERVAL = 7200000; // 2 hours
let MAX_TIMEOUT_DURATION = 86400000; // 24 hours
let running_task_menu,
start_task_menu,
singleton;
/**
* The TaskService class
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.4.0
*/
const Service = Function.inherits('Alchemy.Base', 'Alchemy.Task', function TaskService() {
if (singleton) {
return singleton;
}
// Get the AlchemyTask model
this.AlchemyTask = Model.get('System.Task');
// Keep track of all running tasks
this.running_tasks = [];
// Keep track of next schedule per task
this.schedules = new Map();
// Has the task service finished loading?
this.has_loaded = false;
this.initSchedules();
singleton = this;
});
/**
* Get a TaskSchedule instance for the given taks
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @return {TaskSchedules|string}
*/
Service.setMethod(function getTaskSchedules(constructor) {
if (typeof constructor == 'string') {
constructor = Classes.Alchemy.Task.Task.getMember(constructor);
if (!constructor) {
console.warn('Could not find task constructor', constructor);
}
}
if (!constructor) {
return null;
}
let result = this.schedules.get(constructor);
if (!result) {
result = new TaskSchedules(constructor);
this.schedules.set(constructor, result);
}
return result;
});
/**
* Checksum the given cron schedule & settings
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @param {string} type_path
* @param {Cron} cron
* @param {Object} settings
*
* @return {string}
*/
Service.setMethod(function checksumSystemSchedule(type_path, cron, settings) {
if (!cron) {
return '';
}
if (!settings) {
settings = {};
}
let result = Object.checksum([type_path, cron.toDry(), settings]);
return result;
});
/**
* Initialize the schedules on boot
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.4.0
*/
Service.setMethod(async function initSchedules() {
if (this.has_loaded) {
return;
}
let crit = this.AlchemyTask.find();
crit.where('schedule_type').in(['system_forced', 'system_fallback']);
let existing_system_records = new Map(),
required_system_schedules = new Map();
// Iterate over all the Task classes & see if they have any forced schedules
for (let task_class of Classes.Alchemy.Task.Task.getLiveDescendantsMap()) {
let forced_schedules = task_class.forced_cron_schedules,
fallback_schedules = task_class.fallback_cron_schedules;
// Add the hard-coded forced schedules
if (forced_schedules?.length) {
for (let entry of forced_schedules) {
let checksum = this.checksumSystemSchedule(task_class.type_path, entry.cron_schedule, entry.settings);
required_system_schedules.set(checksum, {
schedule_type : 'system_forced',
task_type : task_class.type_path,
cron : entry.cron_schedule,
settings : entry.settings,
});
}
}
// And the fallback schedules
if (fallback_schedules?.length) {
for (let entry of fallback_schedules) {
let checksum = this.checksumSystemSchedule(task_class.type_path, entry.cron_schedule, entry.settings);
required_system_schedules.set(checksum, {
schedule_type : 'system_fallback',
task_type : task_class.type_path,
cron : entry.cron_schedule,
settings : entry.settings,
});
}
}
}
// Iterate over all the existing system schedules in the database
for await (let record of crit) {
if (!record.schedule_type || record.schedule_type == 'user') {
continue;
}
try {
const checksum = record.forced_schedule_checksum;
// If (for any reason) this system schedule does not have a checksum,
// just remove it
if (!checksum) {
await record.remove();
}
let entry = required_system_schedules.get(checksum);
// If the checksum is not in the required system schedules, remove it
if (!entry || !entry.cron) {
await record.remove();
}
// If this checksum has already been seen, also remove it
if (existing_system_records.has(checksum)) {
await record.remove();
continue;
}
// Update the record
record.frequency = entry.cron.input;
record.settings = entry.settings;
record.schedule_type = entry.schedule_type;
await record.save();
existing_system_records.set(checksum, record);
} catch (err) {
alchemy.registerError(err);
}
}
// Iterate over all the required system schedules and create new ones
for (let [checksum, entry] of required_system_schedules) {
if (existing_system_records.has(checksum)) {
continue;
}
try {
let record = this.AlchemyTask.createDocument();
record.frequency = entry.cron.input;
record.settings = entry.settings;
record.schedule_type = entry.schedule_type;
record.forced_schedule_checksum = checksum;
record.type = entry.task_type;
record.enabled = true;
await record.save();
existing_system_records.set(checksum, record);
} catch (err) {
alchemy.registerError(err);
}
}
// Now we can schedule all the tasks
await this.rescheduleAllTasks();
// Check all the timeout calls every 2 hours
setInterval(() => this.updateTimeoutCalls(), TIMEOUT_UPDATE_INTERVAL);
this.has_loaded = true;
});
/**
* Create the Janeway task menu
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.21
*/
Service.setMethod(function createJanewayTaskMenu() {
if (!alchemy.Janeway || running_task_menu) {
return;
}
running_task_menu = alchemy.Janeway.addIndicator('⌚︎', {
sorter: function sortItems(a, b) {
let result = 0;
if (!a.task_schedule) {
// Things without schedules are titles
result = -10;
} else if (!b.task_schedule) {
// Things without schedules are titles
result = 10;
} else if (a.task_schedule.is_running) {
// If it's running, it should be at the top
result = -5;
} else if (a.task_schedule.has_ended) {
// If it has ended, it should be at the bottom
result = -3;
} else if (!a.task_schedule.next_scheduled_date) {
// If it has no scheduled date, it should be at the bottom
result = 1;
} else {
result = a.task_schedule.next_scheduled_date - b.task_schedule.next_scheduled_date;
}
return result;
}
});
if (!running_task_menu) {
return;
}
if (!running_task_menu.addItem) {
return running_task_menu.remove();
}
running_task_menu.addItem('Currently scheduled tasks:', () => {
console.log('Currently scheduled tasks:', this.schedules);
});
alchemy.Janeway.running_task_menu = running_task_menu;
this.janeway_running_task_menu = running_task_menu;
this.janeway_running_task_menu_entries = [];
start_task_menu = alchemy.Janeway.addIndicator('▶︎', {
area: 'left',
sorter: function(a, b) {
if (!isFinite(a.weight) || !isFinite(b.weight)) {
return b.weight - a.weight;
}
return a.title.toLowerCase().localeCompare(b.title.toLowerCase());
}
});
start_task_menu.addItem('Start a task:', {weight: Infinity});
});
/**
* Update all the schedule `setTimeout` calls that need it
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
Service.setMethod(function updateTimeoutCalls() {
for (let task_schedule of this.schedules.values()) {
task_schedule.updateTimeoutCalls();
}
});
/**
* Schedule forced tasks
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
Service.setMethod(async function rescheduleAllTasks() {
// Iterate over all the existing schedules & clear them
for (let existing_schedule of this.schedules.values()) {
existing_schedule.clearAll();
}
// Iterate over all the Task classes & reschedule them
for (let task_class of Classes.Alchemy.Task.Task.getLiveDescendantsMap()) {
await this.rescheduleTasksOfType(task_class);
}
});
/**
* Get the constructor of the given task type
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @param {string|Function} task_type_path
*
* @return {Function|boolean}
*/
Service.setMethod(function getTaskConstructor(task_type_path) {
let task_class;
if (typeof task_type_path == 'string') {
task_class = Classes.Alchemy.Task.Task.getMember(task_type_path);
if (!task_class) {
console.warn('Could not find task constructor', task_type_path);
return false;
}
} else if (typeof task_type_path == 'function') {
task_class = task_type_path;
} else {
task_class = false;
}
return task_class;
});
/**
* Reschedule all tasks of the given type
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @param {string|Function} task_class
*/
Service.setMethod(async function rescheduleTasksOfType(task_class) {
task_class = this.getTaskConstructor(task_class);
if (!task_class) {
return;
}
let schedules = this.getTaskSchedules(task_class);
if (!schedules) {
return;
}
schedules.clearAll();
const Task = Model.get('System.Task');
const crit = Task.find();
crit.where('type').equals(task_class.type_path);
const records = await Task.find('all', crit);
// Do the user & forced tasks first
for (let task_record of records) {
schedules.createStartMenuEntry(task_record);
// Skip records without a valid frequency or that are disabled
if (!task_record.frequency || !task_record.enabled) {
continue;
}
if (!task_record.schedule_type || task_record.schedule_type == 'system_fallback') {
continue;
}
try {
let cron = new Classes.Alchemy.Cron(task_record.frequency);
let settings = task_record.settings;
schedules.add(cron, settings, task_record);
} catch (err) {
alchemy.registerError(err);
}
}
// Do the fallback tasks now
if (!schedules.length) {
for (let task_record of records) {
// Skip records without a valid frequency or that are disabled
if (!task_record.frequency || !task_record.enabled) {
continue;
}
// Skip records that are not fallbacks
if (task_record.schedule_type != 'system_fallback') {
continue;
}
try {
let cron = new Classes.Alchemy.Cron(task_record.frequency);
let settings = task_record.settings;
schedules.add(cron, settings, task_record);
} catch (err) {
alchemy.registerError(err);
}
}
}
});
/**
* Simple class to keep track of a task's schedule
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
class TaskSchedules {
task_constructor = null;
schedules = [];
start_task_menu_items = new Map();
/**
* Initialize the instance
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
constructor(task_constructor) {
this.task_constructor = task_constructor;
}
/**
* Getter for the amount of schedules
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
get length() {
return this.schedules.length;
}
/**
* Add a schedule (with the given settings)
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @param {Cron} cron The actual schedule
* @param {Object} settings The settings
* @param {Document.AlchemyTask} task_document The task record
*/
add(cron, settings, task_document) {
let schedule = new TaskSchedule(this, cron, settings, task_document);
schedule.calculateNextScheduledDate();
this.schedules.push(schedule);
}
/**
* Update all the timeout calls that need it.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
updateTimeoutCalls() {
for (let task_schedule of this.schedules.values()) {
task_schedule.updateTimeoutCall();
}
}
/**
* Create a start menu entry for the given record
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
createStartMenuEntry(task_record) {
if (!start_task_menu) {
return;
}
let title = task_record.title || task_record.type;
let entry = start_task_menu.addItem('▪︎ ' + title, () => {
this.#startFromMenu(task_record);
});
this.start_task_menu_items.set(task_record, entry);
}
/**
* Start a task from the menu
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.20
*/
async #startFromMenu(task_record) {
console.log('Manually starting', task_record.type, 'task:', task_record);
let schedule = new TaskSchedule(this, null, task_record.settings, task_record);
let task_instance = await schedule.startManually();
console.log(' -- Started:', task_instance);
return task_instance;
}
/**
* Clear all the schedules
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
clearAll() {
for (let entry of this.start_task_menu_items.values()) {
entry.remove();
}
this.start_task_menu_items.clear();
for (let schedule of this.schedules) {
schedule.clearTimer();
}
this.schedules = [];
}
}
/**
* Simple class to keep track of a task's schedule
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
class TaskSchedule {
task_schedules = null;
task_document = null;
cron = null;
settings = null;
next_scheduled_date = null;
timer_id = null;
is_running = false;
has_ended = false;
task_instance = null;
history_document = null;
janeway_menu_item = null;
#change_counter = 0;
#instance_pledge = null;
/**
* Initialize the instance
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*
* @param {TaskSchedules} task_schedules
* @param {Cron} cron
* @param {Object} settings
* @param {Document.AlchemyTask} task_document
*/
constructor(task_schedules, cron, settings, task_document) {
this.task_schedules = task_schedules;
this.task_document = task_document;
this.cron = cron;
this.settings = settings;
}
/**
* Create a new instance with the current settings
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
createInstance() {
let settings = this.settings ? JSON.clone(this.settings) : null;
let result = new this.task_schedules.task_constructor();
result.setPayload(settings);
result.setAlchemyTaskDocument(this.task_document);
result.setAlchemyTaskHistoryDocument(this.history_document);
return result;
}
/**
* Calculate the next scheduled date
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
calculateNextScheduledDate() {
let next_date = this.cron.getNextDate();
this.proposeDate(next_date);
}
/**
* With the current scheduled date,
* check if the timeout call needs to be updated.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
updateTimeoutCall() {
// If it already has a timer_id, or it's running, do nothing
if (this.timer_id != null || this.is_running) {
return;
}
// If there is no next scheduled date, or it's in the past, calculate a new one
if (!this.next_scheduled_date || this.next_scheduled_date < Date.now()) {
return this.calculateNextScheduledDate();
}
// Propose the wanted next scheduled date again
if (this.next_scheduled_date) {
this.#setDate(this.next_scheduled_date);
}
}
/**
* Propose a new scheduled date.
* If another date is already set, the closest one will be used.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
proposeDate(scheduled_date) {
if (this.is_running) {
return;
}
if (!scheduled_date) {
return;
}
if (!this.next_scheduled_date || scheduled_date < this.next_scheduled_date) {
this.#setDate(scheduled_date);
}
}
/**
* Get the AlchemyTaskHistory document for the given date.
* If it does not exist yet, it will be created.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
async getHistoryDocumentForDate(date) {
const TaskHistory = Model.get('System.TaskHistory');
const crit = TaskHistory.find();
crit.where('scheduled_at').equals(date);
crit.where('alchemy_task_id').equals(this.task_document.$pk);
crit.where('type').equals(this.task_schedules.task_constructor.type_path);
crit.where('started_at').isEmpty();
let result = await TaskHistory.find('first', crit);
if (!result) {
result = TaskHistory.createDocument();
// Try to set the possible AlchemyTask document id first
result.alchemy_task_id = this.task_document?.$pk;
// Set when this task is supposed to run
result.scheduled_at = date;
// Set our PID
result.process_id = process.pid;
// Set the type of task
result.type = this.task_schedules.task_constructor.type_path;
// Set the settings
result.settings = this.settings;
// It is not yet running
result.is_running = false;
await result.save();
}
return result;
}
/**
* Set the next scheduled date.
* Current date will be overridden no matter what.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
#setDate(scheduled_date) {
this.clearTimer();
if (!scheduled_date) {
return;
}
if (running_task_menu) {
let title = this.task_document?.title || this.task_schedules.task_constructor.type_name;
title += ' @ ' + scheduled_date.format('Y-m-d H:i:s');
this.janeway_menu_item = running_task_menu.addItem(title, () => this.clickedJanewayMenuItem());
this.janeway_menu_item.task_schedule = this;
}
// Calculate the timeout that should be set
let timeout = scheduled_date.getTime() - Date.now();
if (timeout < 0) {
timeout = 0;
}
// `setTimeout` can only handle 32-bit integers,
// so we can't set timeouts for longer than 24.8 days
// We've even lowered this limit a bit further.
if (timeout >= MAX_TIMEOUT_DURATION) {
return;
}
return this.#setDateAndTimeout(scheduled_date, timeout);
}
/**
* Do something when the Janeway menu item is clicked.
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
clickedJanewayMenuItem() {
console.log('Clicked on scheduled task:', this);
}
/**
* Actually set the date & timeout (and history document)
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
async #setDateAndTimeout(scheduled_date, timeout) {
let current_counter = this.#change_counter;
this.next_scheduled_date = scheduled_date;
let history_document = await this.getHistoryDocumentForDate(scheduled_date);
// If something else triggered a change, do nothing
if (current_counter != this.#change_counter) {
return;
}
this.history_document = history_document;
this.timer_id = setTimeout(() => this.#run(), timeout);
}
/**
* Does the current process own this task?
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
async ownsScheduledTask() {
if (!this.history_document || !this.next_scheduled_date) {
return true;
}
// Get the document again!
let doc = await this.getHistoryDocumentForDate(this.next_scheduled_date);
if (!doc) {
return true;
}
this.history_document = doc;
// If it has already ended, return false
if (doc.ended_at || doc.had_error) {
return false;
}
// If we own the process, return true
if (doc.process_id == process.pid || !doc.process_id) {
return true;
}
// If the process is no longer running, claim it!
if (!alchemy.isProcessRunning(doc.process_id)) {
doc.process_id = process.pid;
return true;
}
return false;
}
/**
* Do a manual start
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.20
*
* @return {Task}
*/
async startManually() {
if (this.is_running) {
return false;
}
let pledge = new Pledge();
this.#instance_pledge = pledge;
this.#run(true);
return pledge;
}
/**
* Actually run the task
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.20
*/
async #run(started_manually = false) {
if (this.is_running) {
return false;
}
this.is_running = true;
let janeway_item;
let janeway_title;
let postfix = '';
if (started_manually) {
postfix += ' (manually)';
}
try {
if (running_task_menu) {
janeway_title = this.task_document?.title || this.task_schedules.task_constructor.type_name;
let title = 'Currently running: ' + janeway_title + postfix;
janeway_item = running_task_menu.addItem(title, () => this.clickedJanewayMenuItem());
janeway_item.task_schedule = this;
}
} catch (err) {
alchemy.registerError(err);
}
try {
if (started_manually || await this.ownsScheduledTask()) {
let instance = this.createInstance();
this.task_instance = instance;
if (this.#instance_pledge) {
this.#instance_pledge.resolve(instance);
this.#instance_pledge = null;
}
await instance.start();
}
} catch (err) {
alchemy.registerError(err);
}
this.history_document = null;
this.next_scheduled_date = null;
this.task_instance = null;
this.is_running = false;
this.has_ended = true;
if (!started_manually) {
this.calculateNextScheduledDate();
}
if (janeway_item) {
let title = 'Finished: ' + janeway_title + postfix;
janeway_item.setContent(title);
setTimeout(() => janeway_item.remove(), 5000);
}
}
/**
* Clear the timer
*
* @author Jelle De Loecker <jelle@elevenways.be>
* @since 1.3.17
* @version 1.3.17
*/
clearTimer() {
clearTimeout(this.timer_id);
this.timer_id = null;
this.next_scheduled_date = null;
this.#change_counter++;
if (this.janeway_menu_item) {
this.janeway_menu_item.remove();
this.janeway_menu_item = null;
}
}
}