UNPKG

topology-runner

Version:

Run a topology consisting of a directed acyclic graph

472 lines (439 loc) 14.2 kB
import _ from 'lodash/fp' import { ObjectOfPromises, DAG, Events, Snapshot, Options, SnapshotData, RunTopologyInternal, ResumeTopology, RunTopology, Spec, NodeType, } from './types' import { findKeys } from './util' import EventEmitter from 'eventemitter3' import makeError from 'make-error' export const TopologyError = makeError('TopologyError') /** * Remove excludesNodes from DAG and from the dependency lists * of nodes. */ const removeExcludeNodes = (excludeNodes: string[]) => _.flow( _.omit(excludeNodes), _.mapValues(_.update('deps', _.pullAll(excludeNodes))) ) /** * Only include nodes from DAG in includeNodes and remove dependencies * not in includeNodes. */ const pickIncludeNodes = (includeNodes: string[]) => _.flow( _.pick(includeNodes), _.mapValues(_.update('deps', _.intersection(includeNodes))) ) /** * Handle excludeNodes and includeNodes options, transforming the * DAG accordingly. Only one option will be handled, not both. */ export const filterDAG = (dag: DAG, options: Options = {}): DAG => { if (options.excludeNodes) return removeExcludeNodes(options.excludeNodes)(dag) if (options.includeNodes) return pickIncludeNodes(options.includeNodes)(dag) return dag } /** * Get the dependents of the node. */ const getDependents = (node: string, dag: DAG) => { const nodes: string[] = [] for (const key in dag) { const { deps } = dag[key] if (deps.includes(node)) { nodes.push(key) } } return nodes } /** * Get a list of nodes where all dependencies have completed and the * node's status is pending. */ export const getNodesReadyToRun = (dag: DAG, data: SnapshotData) => { // Get completed nodes const completed = findKeys({ status: 'completed' }, data) const nodes: string[] = [] for (const node in dag) { const { deps } = dag[node] // The node does not exist or status is pending const isPending = !data[node] || data[node].status === 'pending' // Dependencies for the node are all completed and the node is currently // pending. if (_.difference(deps, completed).length === 0 && isPending) { nodes.push(node) } } return nodes } /** * Get the input data from dependencies. */ export const getInputData = (dag: DAG, node: string, data: SnapshotData) => { // Use existing input data from snapshot if it exists const snapshotInput = _.get([node, 'input'], data) if (snapshotInput) { return snapshotInput } const deps = dag[node].deps const input = [] for (const dep of deps) { const type = dag[dep].type // Input is the output of the dependency if (type === 'work') { input.push(_.get([dep, 'output'], data)) } // Input is the input of the dependency else { const depInput = _.get([dep, 'input'], data) if (depInput) { input.push(...depInput) } } } return input } /** * Update the snapshot when various node-level events take place * and emit the modified snapshot. */ const nodeEventHandler = (snapshot: Snapshot, emitter: EventEmitter<Events>) => (node: string) => { const updateState = (state: any) => { // Update snapshot snapshot.data[node].state = state // Emit emitter.emit('data', snapshot) } const running = (data: any) => { // Update snapshot snapshot.data[node].started = new Date() snapshot.data[node].input = data snapshot.data[node].status = 'running' // Emit emitter.emit('data', snapshot) } const completed = (output?: any) => { // Update snapshot if (output !== undefined) { snapshot.data[node].output = output } snapshot.data[node].status = 'completed' snapshot.data[node].finished = new Date() // Emit emitter.emit('data', snapshot) } const branched = (selected: string, reason?: string) => { // Update snapshot snapshot.data[node].status = 'completed' snapshot.data[node].selected = selected if (reason) { snapshot.data[node].reason = reason } snapshot.data[node].finished = new Date() // Emit emitter.emit('data', snapshot) } const skipped = () => { // Update snapshot snapshot.data[node].status = 'skipped' snapshot.data[node].finished = new Date() // Emit emitter.emit('data', snapshot) } const suspended = () => { // Update snapshot snapshot.data[node].status = 'suspended' snapshot.data[node].finished = new Date() // Emit emitter.emit('data', snapshot) } const errored = (error: any) => { // Update snapshot snapshot.data[node].status = 'errored' snapshot.data[node].finished = new Date() // Capture stack and any abritrary properties if instance of Error snapshot.data[node].error = error instanceof Error ? { ...error, stack: error.stack } : error // Emit emitter.emit('data', snapshot) } return { updateState, running, completed, branched, skipped, suspended, errored, } } /** * Check if all nodes in the DAG are in the spec */ export const getMissingSpecNodes = (spec: Spec, dag: DAG) => _.difference(Object.keys(dag), Object.keys(spec)) const branch = (node: string, reason?: string) => ({ node, reason, }) const NONE = Symbol('NONE') const none = (reason?: string) => ({ node: NONE, reason, }) // eslint-disable-next-line const noOp = async () => {} const _runTopology: RunTopologyInternal = (spec, dag, snapshot, context) => { const missingSpecNodes = getMissingSpecNodes(spec, dag) if (missingSpecNodes.length) { throw new TopologyError( `Missing the following nodes in spec: ${missingSpecNodes.join(', ')}` ) } const abortController = new AbortController() // Track node promises const promises: ObjectOfPromises = {} // Event emitter const emitter = new EventEmitter<Events>() const eventHandler = nodeEventHandler(snapshot, emitter) const start = async () => { // Emit initial snapshot emitter.emit('data', snapshot) // Loop while (true) { // Get nodes with resolved dependencies that have not been run const readyToRunNodes = abortController.signal.aborted ? [] : getNodesReadyToRun(dag, snapshot.data) // There is no more work to be done if (_.isEmpty(readyToRunNodes) && _.isEmpty(promises)) { // Get errored nodes const errored = findKeys({ status: 'errored' }, snapshot.data) const hasErrors = !_.isEmpty(errored) // Was part of the toplogy suspended? const suspended = findKeys({ status: 'suspended' }, snapshot.data) // Update snapshot const status = hasErrors ? 'errored' : suspended.length ? 'suspended' : 'completed' snapshot.status = status // Get nodes with pending status const pending = findKeys({ status: 'pending' }, snapshot.data) // Suspend pending nodes if topology is suspended if (status === 'suspended') { pending.forEach((node) => eventHandler(node).suspended()) } // Skip pending nodes if topology is completed if (status === 'completed') { pending.forEach((node) => eventHandler(node).skipped()) } snapshot.finished = new Date() // Emit emitter.emit(hasErrors ? 'error' : 'done', snapshot) // Throw an exception, causing the promise to reject, if one or more // nodes have errored if (hasErrors) { throw new TopologyError(`Errored nodes: ${JSON.stringify(errored)}`) } // Exit loop return } // Run nodes that have not been run yet for (const node of readyToRunNodes) { // Snapshot updater const events = eventHandler(node) // Get the node from the spec const { run, type } = spec[node] // Get the input data from dependencies const data = getInputData(dag, node, snapshot.data) // Run fn input const baseInput = { data, node, context } // Update snapshot events.running(data) // Get dependent nodes const dependents = getDependents(node, dag) // Handle branching node type if (type === 'branching') { const runFn = async () => run({ ...baseInput, branch, none }) promises[node] = runFn() .then(({ node: selected, reason }) => { events.branched(selected.toString(), reason) // Explicitly skip all dependents if (selected === NONE) { // Skip all dependents dependents.forEach((node) => eventHandler(node).skipped()) } else if (typeof selected === 'string') { // Check if selected branch is a dependent of the node if (!dependents.includes(selected)) { throw new TopologyError(`Branch not found: ${selected}`) } // Skip dependents that were not selected _.pull(selected, dependents).forEach((node) => eventHandler(node).skipped() ) } }) .catch(events.errored) .finally(() => { delete promises[node] }) } // Handle suspension and work node types else { // Callback to update state const updateState = events.updateState // Get node state. Will only be present if resuming. const state = snapshot.data[node]?.state const input = { ...baseInput, updateState, state, signal: abortController.signal, } const handleSuspension = () => { // Set status to completed events.completed() // Suspend all dependent nodes dependents.forEach((node) => eventHandler(node).suspended()) } // The run fn may not exist for suspension node type const runFn = run || noOp promises[node] = runFn(input) .then(type === 'suspension' ? handleSuspension : events.completed) .catch(events.errored) .finally(() => { delete promises[node] }) } } // Wait for a promise to resolve await Promise.race(_.values(promises)) } } const getSnapshot = () => snapshot const stop = () => { abortController.abort() } return { start, stop, emitter, getSnapshot } } const getNodesWithNoDeps = (dag: DAG) => findKeys(({ deps }: { deps: string[] }) => _.isEmpty(deps), dag) /** * Set input for nodes with no dependencies to data, if exists. */ export const initSnapshotData = (dag: DAG, data?: any): SnapshotData => { // Get nodes with no dependencies const noDepsNodes = getNodesWithNoDeps(dag) const snapshotData: SnapshotData = {} for (const node in dag) { snapshotData[node] = { ...dag[node], status: 'pending' } // Data exists and node has no dependencies if (data !== undefined && noDepsNodes.includes(node)) { snapshotData[node].input = [data] } } return snapshotData } const extractDag = ( obj: Record<string, { deps: string[]; type?: NodeType }> ) => { const dag: DAG = {} for (const node in obj) { const nodeDef = obj[node] // Default node type to work if not set dag[node] = { deps: nodeDef.deps, type: nodeDef.type || 'work' } } return dag } /** * Run a topology consisting of a DAG and functions for each node in the * DAG. A subset of the DAG can be executed by setting either includeNodes * or excludeNodes. Initial data is passed via options.data. Optionally * pass a context blob to all nodes. * * Returns an event emitter and a promise. The event emitter emits data * every time the topology snapshot updates. Events include: data, error, and * done. */ export const runTopology: RunTopology = (spec, options) => { const _dag = extractDag(spec) // Get the filtered dag const dag = filterDAG(_dag, options) // Initialize snapshot data const data = initSnapshotData(dag, options?.data) // Initial snapshot const snapshot: Snapshot = { status: 'running', started: new Date(), data, } // Run the topology return _runTopology(spec, dag, snapshot, options?.context) } /** * Set uncompleted nodes to 'pending' and reset appropriate * NodeDef fields. Ignore skipped nodes. */ const resetUncompletedNodes = (data: SnapshotData): SnapshotData => _.mapValues((nodeData) => { const { status, ...obj } = nodeData return status === 'completed' || status === 'skipped' ? nodeData : { ..._.pick(['input', 'state', 'deps', 'type'], obj), status: 'pending', } }, data) /** * Transform a previously run snapshot into one that is ready to * be run again. */ export const getResumeSnapshot = (snapshot: Snapshot) => { const snap: Snapshot = { ...snapshot, status: 'running', started: new Date(), data: resetUncompletedNodes(snapshot.data), } delete snap.finished return snap } /** * Resume a topology from a previous snapshot. Optionally pass a context * blob to all nodes. * * Returns an event emitter and a promise. The event emitter emits data * every time the topology snapshot updates. Events include: data, error, and * done. */ export const resumeTopology: ResumeTopology = (spec, snapshot, options) => { if (!snapshot) { throw new TopologyError('Snapshot is undefined') } // Ensures resumption is idempotent if (snapshot.status === 'completed') { const emitter = new EventEmitter<Events>() const getSnapshot = () => snapshot /* eslint-disable-next-line */ const stop = () => {} return { start: () => Promise.resolve(), stop, emitter, getSnapshot } } // Initialize snapshot for running const snap = getResumeSnapshot(snapshot) const dag = extractDag(snap.data) // Run the topology return _runTopology(spec, dag, snap, options?.context) }