processus
Version:
A simple node workflow engine
676 lines (578 loc) • 20.9 kB
JavaScript
/*
* Processus, by cloudb2, MPL2.0 (See LICENSE file in root dir)
*
* processus.js: The main engine, where the work gets done
*/
var logger = require('./logger');
require('dotenv').load({silent: true});
var async = require("async");
var uuid = require("node-uuid");
var store = require('./persistence/store');
var _ = require("underscore");
module.exports = {
execute: execute,
updateTasks: updateTasks,
runWorkflow: runWorkflow
};
function runWorkflow(defId, id, workflowTaskJSON, callback) {
if (id === null || id === undefined) {
execute(workflowTaskJSON, function(err, workflow){
if(!err) {
logger.debug("Workflow returned successfully.");
logger.debug(JSON.stringify(workflow, null, 2));
if(workflow.status === "completed"){
logger.info("✰ Workflow [" + defId + "] with id [" + workflow.id + "] completed successfully.");
}
else {
logger.info("✰ Workflow [" + defId + "] with id [" + workflow.id + "] exited without error, but did not complete.");
}
}
else {
logger.error("✘ " + err.message);
logger.error("✘ Workflow [" + defId + "] with id [" + workflow.id + "] exited with error!");
logger.debug(JSON.stringify(workflow, null, 2));
}
callback(err, workflow);
});
}
if(id !== null && id !== undefined){
updateTasks(id, workflowTaskJSON, function(err, workflow){
if(!err) {
logger.debug("Workflow returned successfully.");
logger.debug(JSON.stringify(workflow, null, 2));
if(workflow.status === "completed"){
logger.info("✰ Workflow [" + defId + "] with id [" + id + "] updated successfully.");
}
}
else {
logger.error("✘ " + err.message);
logger.error("✘ Workflow [" + defId + "] with id [" + id + "] failed to update with error!");
logger.debug(JSON.stringify(workflow, null, 2));
}
callback(err, workflow);
});
}
}
function updateTasks(id, tasks, callback){
store.loadInstance(id, 0, function(err, workflow){
if(!err){
if(workflow.status !== "completed") {
workflow = mergeTasks(workflow, tasks);
execute(workflow, callback);
}
else {
callback(new Error("Update failed, workflow [" + id + "] has already completed!"));
}
}
else {
callback(err);
}
});
}
function mergeTasks(workflow, tasks) {
function makeTaskHandler(taskName) {
return function taskHandler(task, name) {
if(taskName == name) {
mergeTask(task, tasks[taskName]);
return false;
}
else {
//continue scanning
return true;
}
};
}
taskNames = Object.keys(tasks);
for(var x=0; x<taskNames.length; x++){
scanAllTasks(workflow.tasks, true, makeTaskHandler(taskNames[x]));
}
return workflow;
}
function mergeTask(originalTask, newTask){
//A Handler can only change the data status, conditions and any sub tasks.
originalTask.parameters = newTask.parameters;
originalTask.status = newTask.status;
originalTask.errorIf = newTask.errorIf;
originalTask.skipIf = newTask.skipIf;
originalTask.tasks = newTask.tasks;
//now update time completed
originalTask.timeCompleted = Date.now();
originalTask.totalDuration = originalTask.timeCompleted - originalTask.timeOpened;
}
function addEnvVars(workflow){
var vars = Object.keys(process.env);
workflow.environment = {};
for(var x=0; x<vars.length; x++){
workflow.environment[vars[x]] = process.env[vars[x]];
}
return workflow;
}
/**
* Executes the supplied workflow for this instance of the processus engine.
*
* @param {object} The workflow definition as a JSON object
* @returns {object} The validated and updated workflow object
*/
function execute(workflow, callback){
try {
//add environment vars to workflow parameters
workflow = addEnvVars(workflow);
//Validate workflow
workflow = validateWorkflow(workflow);
//Do pre-workflow Task
doPre(workflow, function(err, workflow){
if(!err) {
realExecute(workflow, function(err, workflow){
if(!err){
doPost(workflow, function(err, workflow){
callback(err, workflow);
});
}
else {
//execute workflow failed, so callback
callback(err, workflow);
return;
}
});
}
else {
//pre workflow failed, so callback
callback(err, workflow);
return;
}
});
}
catch(execError){
callback(execError, workflow);
}
}
function doPre(workflow, callback){
var task = workflow['pre workflow'];
executePrePost(workflow, "pre workflow", task, callback);
}
function doPost(workflow, callback){
var task = workflow['post workflow'];
executePrePost(workflow, "post workflow", task, callback);
}
function executePrePost(workflow, taskName, task, callback){
if(task !== undefined && task !== null) {
setTaskDataValues(workflow, task);
setConditionValues(task);
task.status = "executing";
task.timeOpened = Date.now();
executeTask(workflow.id, taskName, task, function(err, taskObject){
callback(err, workflow);
});
}
else {
//Nothing to do, so carry on
callback(null, workflow);
}
}
//Validate the supplied workflow and remove any non-JSON things, like functions!
//Sets the initial status of ALL the tasks to 'waiting'
function validateWorkflow(workflow){
//convert supplied "JSON" object to string and then back again, thus ensuring
//it really is just JSON (i.e. any evil functions will be ignored)
var sWorkflow = JSON.stringify(workflow);
workflow = JSON.parse(sWorkflow);
//initialise the status of tasks within the workflow
setTaskStatusWaiting(workflow);
//Set workflow UUID if not already present.
if(workflow.id === undefined) {
workflow.id = uuid.v4();
}
//return the result
return workflow;
}
//Returns all the tasks that have the supplied status, starting at the parent
//and recursing into child tasks if deep = true
function getTasksByStatus(parent, status, deep) {
var openTasks={};
scanAllTasks(parent.tasks, deep, function(task, name){
if(task.status === status){
openTasks[name] = task;
}
return true;
});
return openTasks;
}
function executeTask(workflowId, taskName, taskObject, callback){
taskObject.timeStarted = Date.now();
logger.debug("task.skipIf = " + taskObject.skipIf);
logger.debug("task.errorIf = " + taskObject.errorIf);
var skip = (taskObject.skipIf === true ||
taskObject.errorIf === true ||
taskObject.handler === '' ||
taskObject.handler === undefined);
//Set handler executed based on skip evaluation
taskObject.handlerExecuted = !skip;
if (!skip) {
//No error or skip condition so execute handler
logger.info("⧖ Starting task [" + taskName + "]");
try {
require(taskObject.handler)(workflowId, taskName, taskObject, function(err, taskObjectreturned){
//handler returned, check if there's an error
if(err) {
//there is, so set it on the task
taskObjectreturned.errorMsg = err.message;
taskObjectreturned.status = "error";
//Should we ignore the error?
if(taskObjectreturned.ignoreError === true) {
logger.info("ignoring error, as requested for task [" + taskName + "]");
taskObjectreturned.status = 'executing';
//reset err object
err = undefined;
}
}
else {
logger.info("✔ task " + taskName + " completed successfully.");
}
//If the task is executing mark it as completed and update times
if(taskObjectreturned.status === 'executing'){
taskObjectreturned.status = 'completed';
taskObjectreturned.timeCompleted = Date.now();
taskObjectreturned.handlerDuration = taskObjectreturned.timeCompleted - taskObjectreturned.timeStarted;
taskObjectreturned.totalDuration = taskObjectreturned.timeCompleted - taskObjectreturned.timeOpened;
}
//If the task is paused (as marked by the hander) then we assume
//an async call that will return in the future
if(taskObjectreturned.status === 'paused'){
taskObjectreturned.handlerDuration = Date.now() - taskObjectreturned.timeStarted;
}
//callback to Async
callback(err, taskObjectreturned);
}, logger);
}
catch(requireError) {
taskObject.errorMsg = requireError.message;
taskObject.status = "error";
callback(new Error("Possible missing module or other unexpected error! " + requireError), taskObject);
}
}
else {
if(taskObject.skipIf === true) {
logger.debug("skipping handler for task [" + taskName + "]");
}
//Ok, we're skipping the handler is that because errorIf is true?
var err = null;
if (taskObject.errorIf === true) {
err = new Error("Task [" + taskName + "] has error condition set.");
taskObject.errorMsg = err.message;
taskObject.status = "error";
}
//If it's exeucuting but has no handler (i.e. it could just be a parent place holder task),
//mark it completed.
if(taskObject.status === 'executing'){
taskObject.status = 'completed';
taskObject.timeCompleted = Date.now();
taskObject.handlerDuration = taskObject.timeCompleted - taskObject.timeStarted;
taskObject.totalDuration = taskObject.timeCompleted - taskObject.timeOpened;
}
//call back to Async, with error if necessary.
callback(err, taskObject);
}
}
//Execute the resulting workflow asynchronously recursing for each next set of tasks
function realExecute(workflow, callback) {
store.saveInstance(workflow, function(err){
logger.debug("save point a reached.");
if(err){
callback(err, workflow);
return;
}
//Check there's any paused tasks, if so we assume Async and exit current workflow
var pausedTasks = getTasksByStatus(workflow, 'paused', true);
var pausedTaskNames = Object.keys(pausedTasks);
if(pausedTaskNames.length > 0) {
logger.debug("found paused task(s) so returning immediately");
callback(null, workflow);
return;
}
//Open any waiting (and available) tasks
openNextAvailableTask(workflow);
//Get a list of ALL the open tasks
var openTasks = getTasksByStatus(workflow, 'open', true);
var taskNames = Object.keys(openTasks);
//Initialise the task execution queue
var taskExecutionQueue = [];
//This function will return a function to be used by async that calls the
//appopriate handler (as defined by the task)
function makeTaskExecutionFunction(x){
return function(callback){
var taskName = taskNames[x];
var taskObject = openTasks[taskNames[x]];
executeTask(workflow.id, taskName, taskObject, callback);
};
}
//Now cycle through the open tasks, check them to see if they can be executed,
//and if so, pushed onto the queue
for (var x=0; x<taskNames.length; x++){
//make a new execution function ready for async
var taskFunction = makeTaskExecutionFunction(x);
//get the task t
var t = openTasks[taskNames[x]];
//if the task is open and has no children, queue it to execute
if(t.status === 'open' && !t.tasks){
setTaskDataValues(workflow, t);
setConditionValues(t);
t.status = "executing";
taskExecutionQueue.push(taskFunction);
}
//if the task has children, but they're ALL completed, queue it to execute
if(t.status === 'open' && t.tasks){
if(childHasStatus(t, 'completed', true)){
setTaskDataValues(workflow, t);
setConditionValues(t);
t.status = "executing";
taskExecutionQueue.push(taskFunction);
}
}
}
//Assuming we actually have any valid tasks to execute, let async call them in parallel
if(taskExecutionQueue.length > 0) {
//Now execute open tasks
async.parallel(taskExecutionQueue,
//function callback for async when all tasks have finsihed or an error has occured
function(error, results) {
//if no error then cycle through results and update the task statuses
if(!error) {
//ok, all done and no error, so recurse into next set of tasks (if any)
realExecute(workflow, callback);
}
else {
//Now set the overall workflow to error
workflow.status = 'error';
store.saveInstance(workflow, function(err){
logger.debug("save point b reached.");
if(err){
callback(err, workflow);
return;
}
else {
callback(error, workflow);
}
});
}
});
}
else {
//check if ALL tasks are completed, if so, set the workflow status
if(childHasStatus(workflow, 'completed', true)){
workflow.status = 'completed';
}
store.saveInstance(workflow, function(err){
logger.debug("save point c reached.");
done = true;
//None left in the queue so callback
if(err){
callback(err, workflow);
return;
}
else {
callback(null, workflow);
}
});
}
});
}
//check data values and look out for $[] references and update the value accordingly
function setTaskDataValues(workflow, task){
var taskProperties = Object.keys(task);
taskProperties.map(function(propertyKey){
var prop = task[propertyKey];
//convert whole task to JSON string
var propStr = JSON.stringify(prop, null, 2);
logger.debug("checking for $[] in " + propStr);
//Now look for matching '$[]' references
refValues = propStr.match(/[$](\[(.*?)\])/g);
if(refValues) {
//Cycle through fetching the ref values and replacing
for(var x=0; x<refValues.length; x++){
//get current ref value
refValue = refValues[x];
//remove the '$[]' chars
refValue = refValue.substring(2, refValue.length -1);
//get env var
dataValue = getData(workflow, refValue);
if(dataValue === undefined){
dataValue = null;
}
if (typeof dataValue === 'string' || dataValue instanceof String) {
//literally replace env ref with value
dataValue = dataValue
.replace(/[\\]/g, '\\\\')
.replace(/[\/]/g, '\\/')
.replace(/[\b]/g, '\\b')
.replace(/[\f]/g, '\\f')
.replace(/[\n]/g, '\\n')
.replace(/[\r]/g, '\\r')
.replace(/[\t]/g, '\\t')
.replace(/[\"]/g, '\\"')
.replace(/\\'/g, "\\'");
propStr = propStr.replace(refValues[x], dataValue);
}
else {
//ok, not a string, so replace quotes wrapping the path and JSON stringify it
//in case it's a complete object. i.e. allow the passing of objects.
var beforeStr = propStr.replace('"' + refValues[x] + '"', JSON.stringify(dataValue, 2, null));
//if nothing changed, then it may not be a string, but it's part of a
//new string, so just replace it as is
if(propStr == beforeStr){
propStr = propStr.replace(refValues[x], dataValue);
}
else {
propStr = propStr.replace('"' + refValues[x] + '"', JSON.stringify(dataValue, 2, null));
}
}
}
}
task[propertyKey] = JSON.parse(propStr);
});
}
//Init all tasks to waiting and set workflow status to open
function setTaskStatusWaiting(workflow){
workflow.status = 'open';
scanAllTasks(workflow.tasks, true, function(task, name){
if(!task.status) {
task.status = 'waiting';
}
//continue scanning
return true;
});
}
//childHasStatus returns true if at least one of the children have the matching status
//Set all to true if ALL children should match that status
function childHasStatus(parent, status, all){
var matchStatus = false;
//are there child tasks, if not, return false;
if(!parent.tasks) { return false; }
scanAllTasks(parent.tasks, true, function(task, name){
matchStatus = (task.status === status);
if(matchStatus && !all) {
//exit out of the scan
return false;
}
if(!matchStatus && all){
//exit out of the scan
return false;
}
//continue scanning & checking
return true;
});
return matchStatus;
}
//opens next available tasks and reurns if true if there are any waiting
function openNextAvailableTask(workflow){
openTasks(workflow.tasks);
//return true if there are any open tasks
return childHasStatus(workflow, 'open', false);
}
//open any tasks that are 'ready' to be opened
function openTasks(tasks) {
scanAllTasks(tasks, false, function(task, name){
//If... we're still updating, the task is waiting and none of its children are waiting
if(task.status ==='open'){
//task is open, but does it have ANY children waiting?
if(childHasStatus(task, 'waiting', false)){
//it does, so let's go down and check those first
openTasks(task.tasks);
}
//if the task is blocking return false to signify to scanAllTasks that we don't
//to continue scanning
return !isBlocking(task);
}
//the task is waiting, so let's open it and check its children (if any)
if(task.status === "waiting"){
task.status = 'open';
task.timeOpened = Date.now();
//we've opened the task, so open it's children (if any)
if(task.tasks){
openTasks(task.tasks);
}
//if the task is blocking, then don't continue
return !isBlocking(task);
}
//carry on!
return true;
});
}
//scan all tasks calling callback for each task a bit like 'map' for tasks
//set deep to true to recursively scan children
function scanAllTasks(tasks, deep, callback){
var scanning = true;
var taskNames = Object.keys(tasks);
for (var x=0; x<taskNames.length; x++){
//if the callback returns false then break
scanning = callback(tasks[taskNames[x]], taskNames[x]);
if (!scanning){
//bubble up
break;
}
//does the task have children, if scan recursively
if(tasks[taskNames[x]].tasks && deep){
scanning = scanAllTasks(tasks[taskNames[x]].tasks, true, callback);
if (!scanning){
break;
}
}
}
return scanning;
}
//get the data value of the task as reference by the . notation path
function getData(workflow, path){
logger.debug("Getting data for path: " + path);
pathElements = path.split(".");
var obj = workflow;
for (var x=0; x<pathElements.length; x++){
//bug fix, only keep following the path if the object isn't undefined
if(obj !== undefined) {
obj = obj[pathElements[x]];
}
}
if(obj === undefined){
//it's undefined, so we can't get the data, that may be valid.... or not!
logger.warn("Unable to get value for path " + path + " in workflow. Did you set the path correctly?");
}
return obj;
}
function setConditionValues(task){
//now evaluate any conditions (if any)
if(task.skipIf !== undefined) {
task.skipIf = getBoolean(task.skipIf);
}
if(task.errorIf !== undefined) {
task.errorIf = getBoolean(task.errorIf);
}
}
function isBlocking(task) {
var blocking = getBoolean(task.blocking);
return blocking;
}
function getBoolean(value) {
//Is it a boolean anyway?
if(_.isBoolean(value)) {
return value;
}
if(_.isString(value)) {
return (value.toLowerCase() === "true");
}
return false;
}
/*
function exitHandler(options, err) {
logger.debug("Processus is exiting..");
store.exitStore(function(err){
logger.debug("GOODBYE: Cheerio!");
if (options.cleanup) logger.debug("Exit is clean");
if (err) logger.error(err.stack);
if (options.exit) process.exit();
});
}
//do something when app is closing
process.on('exit', exitHandler.bind(null,{cleanup:true}));
//catches ctrl+c event
process.on('SIGINT', exitHandler.bind(null, {exit:true}));
//catches uncaught exceptions
process.on('uncaughtException', exitHandler.bind(null, {exit:true}));
*/