UNPKG

redis-workflow

Version:

Simple Promise based multi-channel workflow rules engine using Redis backing

522 lines (451 loc) 20 kB
import { EventEmitter } from "events"; import * as redis from "redis"; import { Action, ActionType } from "./lib/Action"; import DelayedAction from "./lib/DelayedAction"; import IAction from "./lib/IAction"; import ImmediateAction from "./lib/ImmediateAction"; import IRule from "./lib/IRule"; import ITrigger from "./lib/ITrigger"; import IWorkflow from "./lib/IWorkflow"; import IWorkflowManager from "./lib/IWorkflowManager"; import RedisConfig from "./lib/RedisConfig"; import Rule from "./lib/Rule"; import Trigger from "./lib/Trigger"; import Util from "./lib/Util"; import Workflow from "./lib/Workflow"; export { ActionType, DelayedAction, IAction, ImmediateAction, IRule, ITrigger, IWorkflow, IWorkflowManager, RedisConfig, Rule, Trigger, Util, Workflow, }; export enum WorkflowEvents { Error = "error", // fired when Error emitted Add = "add", // fired when new workflow added Remove = "remove", // fired when workflow removed Load = "load", // fired when workflow loaded from db Save = "save", // fired when workflow saved in db Delete = "delete", // fired when workflow deleted from db Ready = "ready", // fired when manager instantiated Start = "start", // fired when manager started channel Stop = "stop", // fired when manager stopped channel Reset = "reset", // fired when reset Schedule = "schedule", // fired when actions are ActionType.Delay Immediate = "immediate", // fired when actions are ActionType.Immediate Invalid = "invalid", // fired for all workflows when rules do not pass Audit = "audit", // fired for all actions Kill = "kill", // fired when channel stopped listening } export class RedisWorkflowManager extends EventEmitter implements IWorkflowManager { protected client: redis.RedisClient; protected subscriber: redis.RedisClient; protected workflows: Dictionary; // hashmap of channel: IWorkflow[] protected readonly DEFAULT_REDIS_HOST: string = "localhost"; protected readonly DEFAULT_REDIS_PORT: number = 6379; protected readonly PUBSUB_KILL_MESSAGE: string = "WFKILL"; protected readonly REDIS_WORKFLOW_KEY_SUFFIX: string = "workflows"; constructor(config: RedisConfig, client?: redis.RedisClient, channels?: string[]) { super(); if (config && typeof config !== "object") { throw new TypeError("Config must be null or a valid RedisConfig"); } if (client && typeof client !== "object") { throw new TypeError("Client must be null or a valid RedisClient"); } if (channels && channels.length === 0) { throw new TypeError("Channels must be valid array of at least one string"); } if (client && client instanceof redis.RedisClient) { this.client = client; this.subscriber = client; } else { // build properties for instantiating Redis const options: Dictionary = { host: config.host || this.DEFAULT_REDIS_HOST, port: config.port || this.DEFAULT_REDIS_PORT, retry_strategy: (status: any) => { if (status.error && status.error.code === "ECONNREFUSED") { // End reconnecting on a specific error and flush all commands return new Error("The server refused the connection"); } if (status.total_retry_time > 1000 * 60 * 60) { // End reconnecting after a specific timeout and flush all commands return new Error("Retry time exhausted"); } if (status.attempt > 10) { // End reconnecting with built in error return undefined; } // reconnect after return Math.min(status.attempt * 100, 3000); }, }; if (config.db) { options.db = config.db; } if (config.password) { options.password = config.password; } this.client = redis.createClient(options); this.subscriber = redis.createClient(options); } // initiate workflows hash this.workflows = {}; // check if channels declared, and load from db if (channels) { this.reload(channels) .then(() => { return this; }) .catch((error) => { this.emit(WorkflowEvents.Error, error); return this; }); } } public setWorkflows(workflows: Dictionary): void { if (typeof workflows !== "object") { throw new TypeError("Workflows must be a valid object"); } this.workflows = workflows; } public setWorkflowsForChannel(channel: string, workflows: IWorkflow[]): void { if (typeof workflows !== "object") { throw new TypeError("Workflows must be a valid object"); } this.workflows[channel] = workflows; } public getWorkflows(): Dictionary { return this.workflows || {}; } public getWorkflowsForChannel(channel: string): IWorkflow[] { if (typeof channel !== "string") { throw new TypeError("Channel must be a valid string"); } if (!this.workflows) { throw new Error("You haven't defined any workflows yet"); } if (this.workflows && !this.workflows[channel]) { throw new Error("No workflows exist for that channel"); } return this.workflows[channel]; } public addWorkflow(channel: string, workflow: IWorkflow): Promise<void> { return new Promise((resolve, reject) => { if (!channel || typeof channel !== "string") { throw new TypeError("Channel must be a valid string"); } if (!workflow || typeof workflow !== "object") { throw new TypeError("Workflow is required"); } // add workflow or create if non-existant if (!this.workflows) { this.workflows = { [channel]: [workflow] }; } else if (this.workflows && !this.workflows[channel]) { this.workflows[channel] = [workflow]; } else { this.workflows[channel].push(workflow); } // save to database this.saveWorkflowsToDatabaseForChannel(channel) .then(() => { this.emit(WorkflowEvents.Add); resolve(); }) .catch((error) => { reject(error); }); }); } public removeWorkflow(channel: string, name: string): Promise<void> { return new Promise((resolve, reject) => { if (!channel || typeof channel !== "string") { throw new TypeError("Channel must be a valid string"); } if (!name || typeof name !== "string") { throw new TypeError("Name must be a valid string"); } // remove workflow if it exists if (this.workflows && this.workflows[channel]) { let channelFlow: IWorkflow[] = this.workflows[channel]; channelFlow = channelFlow.filter((flow: IWorkflow) => flow.getName() !== name); this.workflows[channel] = channelFlow; } // save to database this.saveWorkflowsToDatabaseForChannel(channel) .then(() => { this.emit(WorkflowEvents.Remove); resolve(); }) .catch((error) => { reject(error); }); }); } public start(channel: string): Promise<void> { return new Promise((resolve, reject) => { if (typeof channel !== "string") { throw new TypeError("Channel must be a valid string"); } if (!this.workflows) { throw new Error("You haven't defined any workflows yet"); } if (this.workflows && !this.workflows[channel]) { throw new Error("No workflows exist for that channel"); } const triggerMap: Dictionary = this.getTriggersAsDictForChannel(channel); // handler for incoming messages on channel this.subscriber.on("message", (ch: string, message: string) => { // only process messages for this channel if (ch === channel) { if (message === this.PUBSUB_KILL_MESSAGE) { this.subscriber.unsubscribe(channel); this.emit(WorkflowEvents.Kill, channel); } else if (message && typeof message === "string") { // parse message and extract event try { const jsonMessage: any = JSON.parse(message); const { event, context } = jsonMessage; const activeFlow: IWorkflow = (event && context) ? triggerMap[event] : null; if (activeFlow) { activeFlow.getActionsForContext(context) .then((actions) => { if (actions.length === 0) { // emit invalid response to handle workflows that rules are not met this.emit(WorkflowEvents.Invalid, jsonMessage); } else { actions.map((action) => { action.setContext(context); if (action) { this.emit(action.getName(), action); // name-based this.emit(WorkflowEvents.Audit, action); // global if (action instanceof DelayedAction) { this.emit(WorkflowEvents.Schedule, action); } else if (action instanceof ImmediateAction) { this.emit(WorkflowEvents.Immediate, action); } } else { this.emit( WorkflowEvents.Error, new TypeError("Action object was null")); } }); } // valid flow }) .catch((error) => { this.emit(WorkflowEvents.Error, error); }); } else { this.emit( WorkflowEvents.Error, new TypeError(`No trigger defined for event '${event}'`)); } } catch (error) { this.emit(WorkflowEvents.Error, error); } // end parse JSON } else { this.emit( WorkflowEvents.Error, new TypeError(`Message ${message} is not valid JSON '{event, context}'`)); } // end if message } // end if channel message }); // start listener this.subscriber.subscribe(channel, (err: Error, reply: string) => { if (err !== null) { throw err; } this.emit(WorkflowEvents.Start); resolve(); }); }); } public stop(channel: string): Promise<void> { return new Promise((resolve, reject) => { if (typeof channel !== "string") { throw new TypeError("Channel parameter must be a string"); } // publish kill message to channel this.client.publish(channel, this.PUBSUB_KILL_MESSAGE, (err: Error, reply: number) => { this.emit(WorkflowEvents.Stop); resolve(); }); }); } public reload(channels: string[]): Promise<void> { return new Promise((resolve, reject) => { if (!channels || channels.length === 0) { throw new TypeError("Channels must be valid array of one or more strings"); } const jobs: Array<Promise<void>> = []; channels.map((ch) => { jobs.push(this.loadWorkflowsFromDatabaseForChannel(ch)); }); // process them Promise.all(jobs) .then((values: any[]) => { this.emit(WorkflowEvents.Ready); resolve(); }) .catch((error) => { this.emit(WorkflowEvents.Error, (error)); }); }); } public save(channels: string[]): Promise<void> { return new Promise((resolve, reject) => { if (!channels || channels.length === 0) { throw new TypeError("Channels must be valid array of one or more strings"); } const jobs: Array<Promise<void>> = []; channels.map((ch) => { jobs.push(this.saveWorkflowsToDatabaseForChannel(ch)); }); // process them Promise.all(jobs) .then((values: any[]) => { this.emit(WorkflowEvents.Save); resolve(); }) .catch((error) => { this.emit(WorkflowEvents.Error, (error)); }); }); } public reset(channel?: string): Promise<void> { return new Promise((resolve, reject) => { // TODO: // loop through workflows (by channel if set) // forEach // remove from db // remove from memory this.workflows = {}; this.emit(WorkflowEvents.Reset); resolve(); }); } protected saveWorkflowsToDatabaseForChannel(channel: string): Promise<void> { return new Promise((resolve, reject) => { if (typeof channel !== "string") { throw new TypeError("Channel parameter must be a string"); } if (this.workflows) { const jobs: Array<Promise<string>> = []; this.workflows[channel].map((workflow: IWorkflow) => { // build key const nameHash: number = Util.hash(workflow.getName()); const key: string = [channel, nameHash].join(":"); // queue job jobs.push(this.saveWorkflowToDatabase(key, workflow)); }); // run jobs and add keys to workflows set Promise.all(jobs) .then((workflowKeys) => { const channelWorkflowId: string = [channel, this.REDIS_WORKFLOW_KEY_SUFFIX].join(":"); this.client.sadd(channelWorkflowId, ...workflowKeys, (err: Error, reply: number) => { this.emit(WorkflowEvents.Save, channel); resolve(); }); }) .catch((error) => { reject(error); }); } }); } protected saveWorkflowToDatabase(key: string, workflow: IWorkflow): Promise<string> { return new Promise((resolve, reject) => { if (!key || typeof key !== "string") { throw new TypeError("Key must be valid string"); } if (!workflow || typeof workflow !== "object") { throw new TypeError("Workflow must be valid Workflow"); } // serialize const workflowDict: Dictionary = workflow.toDict(); this.client.set(key, JSON.stringify(workflowDict), (err: Error, reply: string) => { if (err !== null) { throw err; } resolve(key); }); }); } protected getWorkflowFromDb(key: string): Promise<IWorkflow> { return new Promise((resolve, reject) => { if (!key || typeof key !== "string") { throw new TypeError("Key must be valid string"); } this.client.get(key, (err: Error, reply: string) => { if (err !== null) { throw err; } try { const pFlow: Dictionary = JSON.parse(reply); const pWorkflow: IWorkflow = new Workflow().fromDict(pFlow); resolve(pWorkflow); } catch (error) { reject(error); } }); }); } protected loadWorkflowsFromDatabaseForChannel(channel: string): Promise<void> { return new Promise((resolve, reject) => { if (typeof channel !== "string") { throw new TypeError("Channel parameter must be a string"); } const jobs: Array<Promise<any>> = []; this.client.smembers( [channel, this.REDIS_WORKFLOW_KEY_SUFFIX].join(":"), (err: Error, flows: string[]) => { if (err !== null) { throw err; } // loop through keys and get JSON workflow dict flows.map((key) => { jobs.push(this.getWorkflowFromDb(key)); }); // flows Promise.all(jobs) .then((pWorkflows) => { this.setWorkflowsForChannel(channel, pWorkflows); this.emit(WorkflowEvents.Load); resolve(); }) .catch((error) => { throw error; }); }); // smembers }); } protected removeWorkflowsFromDatabase(channel: string): Promise<void> { return new Promise((resolve, reject) => { if (typeof channel !== "string") { throw new TypeError("Channel parameter must be a string"); } const key: string = [channel, this.REDIS_WORKFLOW_KEY_SUFFIX].join(":"); this.client.del(key, (err: Error, reply: number) => { this.emit(WorkflowEvents.Delete, channel); resolve(); }); }); } protected getTriggersAsDictForChannel(channel: string): Dictionary { const triggerDict: Dictionary = {}; this.workflows[channel].map((flow: IWorkflow) => { if (flow && flow !== null && flow.getTrigger() !== null && flow.getTrigger().getName() !== null && flow.getTrigger().getName() !== undefined) { triggerDict[flow.getTrigger().getName()] = flow; } }); return triggerDict; } }