UNPKG

topology-runner

Version:

Run a topology consisting of a directed acyclic graph

1,097 lines (1,081 loc) 29 kB
import { describe, expect, test } from '@jest/globals' import _ from 'lodash/fp' import { getInputData, filterDAG, getNodesReadyToRun, initSnapshotData, runTopology, resumeTopology, getResumeSnapshot, TopologyError, } from './topology' import { DAG, RunFn, Snapshot, Spec } from './types' import timers from 'timers/promises' const dag: DAG = { api: { deps: [], type: 'work' }, details: { deps: ['api'], type: 'work' }, attachments: { deps: ['api'], type: 'work' }, writeToDB: { deps: ['details', 'attachments'], type: 'work' }, } const branchingDag: DAG = { lookup: { deps: [], type: 'work' }, determineIfQualified: { deps: ['lookup'], type: 'branching' }, qualified: { deps: ['determineIfQualified'], type: 'work' }, notQualified: { deps: ['determineIfQualified'], type: 'work' }, removeCandidate: { deps: ['notQualified'], type: 'work' }, } const branchingSpec: Spec = { // Simulate DB lookup by email lookup: { deps: [], run: async ({ data }) => { const email = data[0]?.email if (email === 'bob@example.com') { return { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', } } if (email === 'tom@example.com') { return { yearsOfExperience: 3, currentEmployer: 'Microsoft', email: 'tom@example.com', } } }, }, // Branch based on output from previous node determineIfQualified: { deps: ['lookup'], type: 'branching', run: ({ data, branch, none }) => { const { email, yearsOfExperience } = data[0] || {} if (email) { if (yearsOfExperience > 3) { return branch('qualified', 'more than 3 years experience') } return branch('notQualified') } return none('email not found') }, }, qualified: { deps: ['determineIfQualified'], run: async () => { // Simulate sending a thank you email await timers.setTimeout(100) }, }, notQualified: { deps: ['determineIfQualified'], run: async () => { // Simulate sending a not qualified email await timers.setTimeout(100) }, }, removeCandidate: { deps: ['notQualified'], run: async () => { // Simulate DB call await timers.setTimeout(100) }, }, } const suspensionDag: DAG = { input: { deps: [], type: 'work' }, lookupA: { deps: ['input'], type: 'work' }, lookupB: { deps: ['input'], type: 'work' }, authorization: { deps: ['lookupA', 'lookupB'], type: 'suspension' }, email: { deps: ['authorization'], type: 'work' }, } const suspensionSpec: Spec = { input: { deps: [], type: 'work', run: async () => 'Southern California' }, lookupA: { deps: ['input'], type: 'work', run: async () => ({ creditScore: 750, }), }, lookupB: { deps: ['input'], type: 'work', run: async () => ({ risk: 'low' }), }, authorization: { deps: ['lookupA', 'lookupB'], type: 'suspension', }, email: { deps: ['authorization'], type: 'work', run: async () => ({ success: true, }), }, } const spec: Spec = { api: { deps: [], run: async () => [1, 2, 3], }, details: { deps: ['api'], run: async ({ data }) => { const ids: number[] = data[0] return ids.reduce((acc, n) => _.set(n, `description ${n}`, acc), {}) }, }, attachments: { deps: ['api'], run: async ({ data }) => { const ids: number[] = data[0] return ids.reduce((acc, n) => _.set(n, `file${n}.jpg`, acc), {}) }, }, writeToDB: { deps: ['details', 'attachments'], run: async () => null, }, } describe('filterDAG', () => { test('exclude nodes', () => { expect(filterDAG(dag, { excludeNodes: ['api'] })).toEqual({ details: { deps: [], type: 'work' }, attachments: { deps: [], type: 'work' }, writeToDB: { deps: ['details', 'attachments'], type: 'work' }, }) }) test('include nodes', () => { expect(filterDAG(dag, { includeNodes: ['details', 'writeToDB'] })).toEqual({ details: { deps: [], type: 'work' }, writeToDB: { deps: ['details'], type: 'work' }, }) }) test('no options', () => { expect(filterDAG(dag)).toEqual(dag) }) }) describe('getNodesReadyToRun', () => { test('resume - pending', () => { const nodes = getNodesReadyToRun(dag, { api: { type: 'work', deps: [], status: 'pending', started: new Date('2022-01-01T12:00:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], }, }) expect(nodes).toEqual(['api']) }) test('deps met - exclude completed', () => { const nodes = getNodesReadyToRun(dag, { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, }) expect(nodes).toEqual(['details', 'attachments']) }) test('deps met - exclude completed and running', () => { const nodes = getNodesReadyToRun(dag, { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, details: { type: 'work', deps: ['api'], status: 'running', started: new Date('2022-01-01T12:00:00Z'), input: [['123', '456']], }, }) expect(nodes).toEqual(['attachments']) }) test('deps met - exclude completed and errored', () => { const nodes = getNodesReadyToRun(dag, { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, details: { type: 'work', deps: ['api'], status: 'errored', started: new Date('2022-01-01T12:00:00Z'), input: [['123', '456']], }, }) expect(nodes).toEqual(['attachments']) }) test('deps not met', () => { const nodes = getNodesReadyToRun(dag, { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, details: { type: 'work', deps: ['api'], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), input: [['123', '456']], output: { '123': 'foo', '456': 'bar' }, }, attachments: { type: 'work', deps: ['api'], status: 'errored', started: new Date('2022-01-01T12:00:00Z'), input: [['123', '456']], }, }) expect(nodes).toEqual([]) }) test('empty deps', () => { const nodes = getNodesReadyToRun(dag, {}) expect(nodes).toEqual(['api']) }) }) describe('getInputData', () => { test('single dep', () => { const input = getInputData(dag, 'details', { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, }) expect(input).toEqual([['123', '456']]) }) test('multiple deps', () => { const input = getInputData(dag, 'writeToDB', { api: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], output: ['123', '456'], }, details: { type: 'work', deps: ['api'], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [['123', '456']], output: { 123: { description: 'foo' } }, }, attachments: { type: 'work', deps: ['api'], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [['123', '456']], output: { 123: { file: 'foo.jpg' } }, }, }) expect(input).toEqual([ { 123: { description: 'foo' } }, { 123: { file: 'foo.jpg' } }, ]) }) test('handle branching', () => { const input = getInputData(branchingDag, 'qualified', { lookup: { type: 'work', deps: [], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: ['bob@example.com'], output: { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, }, determineIfQualified: { type: 'branching', deps: ['lookup'], status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [ { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, ], }, }) expect(input).toEqual([ { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, ]) }) test('handle suspension', () => { const input = getInputData(suspensionDag, 'email', { input: { deps: [], type: 'work', status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: ['123 Main St, Los Angeles, CA'], output: 'Southern California', }, lookupA: { deps: ['input'], type: 'work', status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: ['Southern California'], output: { creditScore: 750 }, }, lookupB: { deps: ['input'], type: 'work', status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: ['Southern California'], output: { risk: 'low' }, }, authorization: { deps: ['lookupA', 'lookupB'], type: 'suspension', status: 'completed', started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:05:00Z'), input: [{ creditScore: 750 }, { risk: 'low' }], }, }) expect(input).toEqual([{ creditScore: 750 }, { risk: 'low' }]) }) test('resume scenario', () => { const input = getInputData(dag, 'api', { api: { type: 'work', deps: [], status: 'pending', input: [ { startDate: '2020-01-01', endDate: '2020-12-31', }, ], state: '2020-04-01', }, }) expect(input).toEqual([ { startDate: '2020-01-01', endDate: '2020-12-31', }, ]) }) }) describe('initData', () => { test('data passed', () => { expect(initSnapshotData(dag, [1, 2, 3])).toEqual({ api: { deps: [], status: 'pending', input: [[1, 2, 3]], type: 'work' }, details: { deps: ['api'], status: 'pending', type: 'work' }, attachments: { deps: ['api'], status: 'pending', type: 'work' }, writeToDB: { deps: ['details', 'attachments'], status: 'pending', type: 'work', }, }) }) test('data empty', () => { expect(initSnapshotData(dag)).toEqual({ api: { deps: [], status: 'pending', type: 'work' }, details: { deps: ['api'], status: 'pending', type: 'work' }, attachments: { deps: ['api'], status: 'pending', type: 'work' }, writeToDB: { deps: ['details', 'attachments'], status: 'pending', type: 'work', }, }) }) }) describe('runTopology', () => { test('nodes receive expected input', async () => { const spec: Spec = { api: { deps: [], run: async ({ data, context }) => ({ data, context, }), }, details: { deps: ['api'], run: async ({ data, context }) => ({ data, context, }), }, } const data = [1, 2, 3] const context = { launchMissleCode: 1234 } const { start, getSnapshot } = runTopology(spec, { data, context }) await start() expect(getSnapshot()).toMatchObject({ status: 'completed', data: { api: { input: [[1, 2, 3]], status: 'completed', output: { data: [[1, 2, 3]], context: { launchMissleCode: 1234 }, }, }, details: { input: [ { data: [[1, 2, 3]], context: { launchMissleCode: 1234 }, }, ], status: 'completed', output: { data: [ { data: [[1, 2, 3]], context: { launchMissleCode: 1234 }, }, ], context: { launchMissleCode: 1234 }, }, }, }, }) }) test('completed', async () => { const { start, getSnapshot } = runTopology(spec) await start() expect(getSnapshot()).toMatchObject({ status: 'completed', data: { api: { deps: [], status: 'completed', input: [], output: [1, 2, 3], }, details: { deps: ['api'], status: 'completed', input: [[1, 2, 3]], output: { '1': 'description 1', '2': 'description 2', '3': 'description 3', }, }, attachments: { deps: ['api'], status: 'completed', input: [[1, 2, 3]], output: { '1': 'file1.jpg', '2': 'file2.jpg', '3': 'file3.jpg' }, }, writeToDB: { deps: ['details', 'attachments'], status: 'completed', input: [ { '1': 'description 1', '2': 'description 2', '3': 'description 3', }, { '1': 'file1.jpg', '2': 'file2.jpg', '3': 'file3.jpg' }, ], output: null, }, }, }) }) test('branching', async () => { const { start, getSnapshot } = runTopology(branchingSpec, { data: { email: 'bob@example.com' }, }) await start() expect(getSnapshot()).toMatchObject({ status: 'completed', data: { lookup: { deps: [], type: 'work', status: 'completed', input: [{ email: 'bob@example.com' }], output: { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, }, determineIfQualified: { deps: ['lookup'], type: 'branching', status: 'completed', input: [ { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, ], selected: 'qualified', reason: 'more than 3 years experience', }, qualified: { deps: ['determineIfQualified'], type: 'work', status: 'completed', input: [ { yearsOfExperience: 5, currentEmployer: 'GovSpend', email: 'bob@example.com', }, ], }, notQualified: { deps: ['determineIfQualified'], type: 'work', status: 'skipped', }, removeCandidate: { deps: ['notQualified'], type: 'work', status: 'skipped', }, }, }) }) test('branching 2', async () => { const { start, getSnapshot } = runTopology(branchingSpec, { data: { email: 'tom@example.com' }, }) await start() expect(getSnapshot()).toMatchObject({ status: 'completed', data: { lookup: { deps: [], type: 'work', status: 'completed', input: [{ email: 'tom@example.com' }], output: { yearsOfExperience: 3, currentEmployer: 'Microsoft', email: 'tom@example.com', }, }, determineIfQualified: { deps: ['lookup'], type: 'branching', status: 'completed', input: [ { yearsOfExperience: 3, currentEmployer: 'Microsoft', email: 'tom@example.com', }, ], selected: 'notQualified', }, qualified: { deps: ['determineIfQualified'], type: 'work', status: 'skipped', }, notQualified: { deps: ['determineIfQualified'], type: 'work', status: 'completed', input: [ { yearsOfExperience: 3, currentEmployer: 'Microsoft', email: 'tom@example.com', }, ], }, removeCandidate: { deps: ['notQualified'], type: 'work', status: 'completed', input: [undefined], }, }, }) }) test('branching none', async () => { const { start, getSnapshot } = runTopology(branchingSpec, { data: { email: 'joe@example.com' }, }) await start() expect(getSnapshot()).toMatchObject({ status: 'completed', data: { lookup: { deps: [], type: 'work', status: 'completed', input: [{ email: 'joe@example.com' }], }, determineIfQualified: { deps: ['lookup'], type: 'branching', status: 'completed', input: [undefined], selected: 'Symbol(NONE)', reason: 'email not found', }, qualified: { deps: ['determineIfQualified'], type: 'work', status: 'skipped', }, notQualified: { deps: ['determineIfQualified'], type: 'work', status: 'skipped', }, removeCandidate: { deps: ['notQualified'], type: 'work', status: 'skipped', }, }, }) }) test('branching error', async () => { const spec: Spec = { branch: { deps: [], type: 'branching', run: ({ branch }) => { return branch('foo') }, }, bar: { deps: ['branch'], run: async () => 1, }, baz: { deps: ['branch'], run: async () => 2, }, } const { start, getSnapshot } = runTopology(spec) await expect(start()).rejects.toThrow('Errored nodes: ["branch"]') // Node errored expect(getSnapshot()).toMatchObject({ status: 'errored', data: { branch: { input: [], status: 'errored', error: { stack: expect.stringContaining('Branch not found: foo') }, }, }, }) }) test('suspend and resume', async () => { const { start, getSnapshot } = runTopology(suspensionSpec) await start() const snapshot = getSnapshot() expect(snapshot).toMatchObject({ status: 'suspended', data: { input: { deps: [], type: 'work', status: 'completed' }, lookupA: { deps: ['input'], type: 'work', status: 'completed', }, lookupB: { deps: ['input'], type: 'work', status: 'completed', }, authorization: { deps: ['lookupA', 'lookupB'], type: 'suspension', status: 'completed', }, }, }) const resume = resumeTopology(suspensionSpec, snapshot) await resume.start() expect(resume.getSnapshot()).toMatchObject({ status: 'completed', data: { input: { deps: [], type: 'work', status: 'completed' }, lookupA: { deps: ['input'], type: 'work', status: 'completed', }, lookupB: { deps: ['input'], type: 'work', status: 'completed', }, authorization: { deps: ['lookupA', 'lookupB'], type: 'suspension', status: 'completed', }, email: { deps: ['authorization'], type: 'work', status: 'completed' }, }, }) }) test('gracefully shutdown when stop is called', async () => { const spec: Spec = { api: { deps: [], run: async ({ signal, updateState }) => { for (let i = 0; i < 5; i++) { if (signal.aborted) { throw new Error('Aborted') } await timers.setTimeout(100) updateState({ index: i }) } }, }, } const { start, stop, getSnapshot } = runTopology(spec) setTimeout(stop, 200) await expect(start()).rejects.toThrow('Errored nodes: ["api"]') // Node errored expect(getSnapshot()).toMatchObject({ status: 'errored', data: { api: { input: [], status: 'errored', state: { index: 1 }, error: { stack: expect.stringContaining('Error: Aborted') }, }, }, }) }) }) describe('getResumeSnapshot', () => { test('transform errored snapshot for resumption', () => { const errorSnapshot: Snapshot = { status: 'errored', started: new Date('2020-01-01T00:00:00Z'), finished: new Date('2020-01-01T00:00:01Z'), data: { api: { type: 'work', deps: [], started: new Date('2020-01-01T00:00:00Z'), finished: new Date('2020-01-01T00:00:01Z'), input: [], status: 'completed', output: [1, 2, 3], }, details: { type: 'work', deps: ['api'], started: new Date('2020-01-01T00:00:00Z'), finished: new Date('2020-01-01T00:00:01Z'), input: [[1, 2, 3]], status: 'completed', output: { '1': 'description 1', '2': 'description 2', '3': 'description 3', }, }, attachments: { type: 'work', deps: ['api'], started: new Date('2020-01-01T00:00:00Z'), input: [[1, 2, 3]], status: 'errored', error: 'fail', state: 0, }, }, } const snapshot = getResumeSnapshot(errorSnapshot) // Started is set to current time expect(snapshot.started.getTime()).toBeGreaterThan( errorSnapshot.started.getTime() ) // Finished should be removed expect(snapshot.finished).toBeUndefined() expect(snapshot).toMatchObject({ status: 'running', data: { api: { started: new Date('2020-01-01T00:00:00.000Z'), finished: new Date('2020-01-01T00:00:01.000Z'), input: [], status: 'completed', output: [1, 2, 3], }, details: { started: new Date('2020-01-01T00:00:00.000Z'), finished: new Date('2020-01-01T00:00:01.000Z'), input: [[1, 2, 3]], status: 'completed', output: { '1': 'description 1', '2': 'description 2', '3': 'description 3', }, }, attachments: { input: [[1, 2, 3]], state: 0, status: 'pending', }, }, }) }) }) describe('resumeTopology', () => { let attempt = 1 const attachmentsRun: RunFn = async ({ data, state, updateState }) => { // Flatten data = data.flat() // Start from next element if resume scenario const ids: number[] = state ? data.slice(state.index + 1) : data const output: Record<number, string> = state ? state.output : {} for (let i = 0; i < ids.length; i++) { const id = ids[i] output[id] = `file${id}.jpg` // Simulate error while processing second element. // Error occurs the first time the fn is called. if (i === 1 && attempt++ === 1) { throw new Error(`Failed processing id: ${id}`) } // Successfully processed so record state updateState({ index: i, output }) } return output } const modifiedSpec = _.set('attachments.run', attachmentsRun, spec) test('resume after initial error', async () => { const { start, getSnapshot } = runTopology(modifiedSpec) await expect(start()).rejects.toThrow('Errored nodes: ["attachments"]') const snapshot = getSnapshot() expect(snapshot).toMatchObject({ status: 'errored', data: { api: { deps: [], input: [], status: 'completed', output: [1, 2, 3], }, details: { deps: ['api'], input: [[1, 2, 3]], status: 'completed', output: { '1': 'description 1', '2': 'description 2', '3': 'description 3', }, }, attachments: { deps: ['api'], input: [[1, 2, 3]], status: 'errored', error: { stack: expect.stringContaining('Error: Failed processing id: 2'), }, state: { index: 0, output: { '1': 'file1.jpg', }, }, }, }, }) const { start: start2, getSnapshot: getSnapshot2 } = await resumeTopology( modifiedSpec, snapshot ) await start2() expect(getSnapshot2()).toMatchObject({ status: 'completed', data: { api: { deps: [], input: [], status: 'completed', output: [1, 2, 3], }, details: { deps: ['api'], input: [[1, 2, 3]], status: 'completed', output: { 1: 'description 1', 2: 'description 2', 3: 'description 3', }, }, attachments: { deps: ['api'], input: [[1, 2, 3]], status: 'completed', output: { 1: 'file1.jpg', 2: 'file2.jpg', 3: 'file3.jpg', }, }, writeToDB: { deps: ['details', 'attachments'], input: [ { 1: 'description 1', 2: 'description 2', 3: 'description 3', }, { 1: 'file1.jpg', 2: 'file2.jpg', 3: 'file3.jpg', }, ], status: 'completed', output: null, }, }, }) }) test('resuming completed snapshot should be idempotent', async () => { const snapshot: Snapshot = { started: new Date('2022-01-01T12:00:00Z'), finished: new Date('2022-01-01T12:00:01Z'), status: 'completed', data: { api: { type: 'work', deps: [], input: [1, 2, 3], status: 'completed', output: { data: [1, 2, 3], }, }, details: { type: 'work', deps: ['api'], input: [ { data: [1, 2, 3], }, ], status: 'completed', output: { data: [ { data: [1, 2, 3], }, ], }, }, }, } const { start, getSnapshot } = resumeTopology(spec, snapshot) await start() expect(getSnapshot()).toEqual(snapshot) }) test('should throw if snapshot is undefined', async () => { const snapshot = undefined expect(() => { resumeTopology(spec, snapshot) }).toThrow(new TopologyError('Snapshot is undefined')) }) })