Skip to content

Commit 82afe62

Browse files
Runtime: warn when an expression doesn't return state (#832)
* Runtime: warn when an expression doesn't return state * test: fix flaky test * feat: update logs wording * test: allow test for undefined at step level * test: warn when an operation does not return state * refactor: move null-state check to begining of prepare-final-state * Runtime: update changelog * versoins: [email protected] [email protected] --------- Co-authored-by: Joe Clark <[email protected]>
1 parent 4376f7b commit 82afe62

File tree

20 files changed

+157
-37
lines changed

20 files changed

+157
-37
lines changed

integration-tests/execute/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# @openfn/integration-tests-execute
22

3+
## 1.0.9
4+
5+
### Patch Changes
6+
7+
- Updated dependencies [1cbbba0]
8+
- @openfn/runtime@1.5.3
9+
310
## 1.0.8
411

512
### Patch Changes

integration-tests/execute/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@openfn/integration-tests-execute",
33
"private": true,
4-
"version": "1.0.8",
4+
"version": "1.0.9",
55
"description": "Job execution tests",
66
"author": "Open Function Group <[email protected]>",
77
"license": "ISC",

integration-tests/worker/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @openfn/integration-tests-worker
22

3+
## 1.0.67
4+
5+
### Patch Changes
6+
7+
- @openfn/engine-multi@1.4.3
8+
- @openfn/lightning-mock@2.0.24
9+
- @openfn/ws-worker@1.8.4
10+
311
## 1.0.66
412

513
### Patch Changes

integration-tests/worker/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@openfn/integration-tests-worker",
33
"private": true,
4-
"version": "1.0.66",
4+
"version": "1.0.67",
55
"description": "Lightning WOrker integration tests",
66
"author": "Open Function Group <[email protected]>",
77
"license": "ISC",

packages/cli/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @openfn/cli
22

3+
## 1.8.10
4+
5+
### Patch Changes
6+
7+
- Warn when an expression doesn't return state
8+
- Updated dependencies [1cbbba0]
9+
- @openfn/runtime@1.5.3
10+
311
## 1.8.9
412

513
### Patch Changes

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/cli",
3-
"version": "1.8.9",
3+
"version": "1.8.10",
44
"description": "CLI devtools for the openfn toolchain.",
55
"engines": {
66
"node": ">=18",

packages/engine-multi/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# engine-multi
22

3+
## 1.4.3
4+
5+
### Patch Changes
6+
7+
- Updated dependencies [1cbbba0]
8+
- @openfn/runtime@1.5.3
9+
310
## 1.4.2
411

512
### Patch Changes

packages/engine-multi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/engine-multi",
3-
"version": "1.4.2",
3+
"version": "1.4.3",
44
"description": "Multi-process runtime engine",
55
"main": "dist/index.js",
66
"type": "module",

packages/engine-multi/test/integration.test.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,19 +151,20 @@ test.serial('trigger workflow-log for job logs', (t) => {
151151

152152
let didLog = false;
153153

154-
api.execute(plan, emptyState).on('workflow-log', (evt) => {
155-
if (evt.name === 'JOB') {
156-
didLog = true;
157-
t.deepEqual(evt.message, JSON.stringify(['hola']));
158-
t.pass('workflow logged');
159-
}
160-
});
161-
162-
api.execute(plan, emptyState).on('workflow-complete', (evt) => {
163-
t.true(didLog);
164-
t.falsy(evt.state.errors);
165-
done();
166-
});
154+
api
155+
.execute(plan, emptyState)
156+
.on('workflow-log', (evt) => {
157+
if (evt.name === 'JOB') {
158+
didLog = true;
159+
t.deepEqual(evt.message, JSON.stringify(['hola']));
160+
t.pass('workflow logged');
161+
}
162+
})
163+
.on('workflow-complete', (evt) => {
164+
t.true(didLog);
165+
t.falsy(evt.state.errors);
166+
done();
167+
});
167168
});
168169
});
169170

packages/lightning-mock/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @openfn/lightning-mock
22

3+
## 2.0.24
4+
5+
### Patch Changes
6+
7+
- Updated dependencies [1cbbba0]
8+
- @openfn/runtime@1.5.3
9+
- @openfn/engine-multi@1.4.3
10+
311
## 2.0.23
412

513
### Patch Changes

packages/lightning-mock/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/lightning-mock",
3-
"version": "2.0.23",
3+
"version": "2.0.24",
44
"private": true,
55
"description": "A mock Lightning server",
66
"main": "dist/index.js",

packages/runtime/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @openfn/runtime
22

3+
## 1.5.3
4+
5+
### Patch Changes
6+
7+
- 1cbbba0: warn when an expression doesn't return state
8+
39
## 1.5.2
410

511
### Patch Changes

packages/runtime/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/runtime",
3-
"version": "1.5.2",
3+
"version": "1.5.3",
44
"description": "Job processing runtime.",
55
"type": "module",
66
"exports": {

packages/runtime/src/execute/expression.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import {
1818
} from '../errors';
1919
import type { JobModule, ExecutionContext } from '../types';
2020
import { ModuleInfoMap } from '../modules/linker';
21+
import {
22+
clearNullState,
23+
isNullState,
24+
createNullState,
25+
} from '../util/null-state';
2126

2227
export type ExecutionErrorWrapper = {
2328
state: any;
@@ -105,8 +110,21 @@ export const wrapOperation = (
105110
return async (state: State) => {
106111
logger.debug(`Starting operation ${name}`);
107112
const start = new Date().getTime();
113+
if (isNullState(state)) {
114+
clearNullState(state);
115+
logger.warn(
116+
`WARNING: No state was passed into operation ${name}. Did the previous operation return state?`
117+
);
118+
}
108119
const newState = immutableState ? clone(state) : state;
109-
const result = await fn(newState);
120+
121+
let result = await fn(newState);
122+
123+
if (!result) {
124+
logger.debug(`Warning: operation ${name} did not return state`);
125+
result = createNullState();
126+
}
127+
110128
// TODO should we warn if an operation does not return state?
111129
// the trick is saying WHICH operation without source mapping
112130
const duration = printDuration(new Date().getTime() - start);

packages/runtime/src/execute/step.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
NOTIFY_JOB_ERROR,
1616
NOTIFY_JOB_START,
1717
} from '../events';
18-
import stringify from 'fast-safe-stringify';
18+
import { isNullState } from '../util/null-state';
1919

2020
const loadCredentials = async (
2121
job: Job,
@@ -80,6 +80,7 @@ const prepareFinalState = (
8080
logger: Logger,
8181
statePropsToRemove?: string[]
8282
) => {
83+
if (isNullState(state)) return undefined;
8384
if (state) {
8485
if (!statePropsToRemove) {
8586
// As a strict default, remove the configuration key
@@ -94,12 +95,12 @@ const prepareFinalState = (
9495
removedProps.push(prop);
9596
}
9697
});
97-
logger.debug(
98-
`Cleaning up state. Removing keys: ${removedProps.join(', ')}`
99-
);
98+
if (removedProps.length)
99+
logger.debug(
100+
`Cleaning up state. Removing keys: ${removedProps.join(', ')}`
101+
);
100102

101-
const cleanState = stringify(state);
102-
return JSON.parse(cleanState);
103+
return clone(state);
103104
}
104105
return state;
105106
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// This module manages a special state object with a hidden null symbol.
2+
// Used to track when operations and jobs do not return their own state
3+
4+
const NULL_STATE = Symbol('null_state');
5+
6+
// The good thing about using a Symbol is that even if we forget to clean the object.
7+
// it's still represented as {}, because symbols aren't visible as keys
8+
export function createNullState() {
9+
return { [NULL_STATE]: true };
10+
}
11+
12+
export function isNullState(state: any) {
13+
return typeof state === 'object' && state[NULL_STATE] === true;
14+
}
15+
16+
export function clearNullState(state: any) {
17+
if (typeof state === 'object') delete state[NULL_STATE];
18+
}

packages/runtime/test/execute/expression.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Operation, State } from '@openfn/lexicon';
55

66
import execute, { mergeLinkerOptions } from '../../src/execute/expression';
77
import type { ExecutionContext } from '../../src/types';
8+
import { isNullState } from '../../src/util/null-state';
89

910
type TestState = State & {
1011
data: {
@@ -139,16 +140,43 @@ test.serial('async operations run in series', async (t) => {
139140
t.is(result.data.x, 12);
140141
});
141142

142-
test.serial('jobs can return undefined', async (t) => {
143+
test.serial(
144+
'jobs return null-state instead of undefined or null',
145+
async (t) => {
146+
// @ts-ignore violating the operation contract here
147+
const job = [() => undefined] as Operation[];
148+
149+
const state = createState() as TestState;
150+
const context = createContext();
151+
152+
const result = (await execute(context, job, state, {})) as TestState;
153+
154+
t.assert(isNullState(result));
155+
}
156+
);
157+
158+
test.serial('warn when an operation does not return state', async (t) => {
143159
// @ts-ignore violating the operation contract here
144-
const job = [() => undefined] as Operation[];
160+
const job = [
161+
(s) => s,
162+
() => {},
163+
(s) => {
164+
s.data = { a: 'a' };
165+
return s;
166+
},
167+
] as Operation[];
145168

146169
const state = createState() as TestState;
147170
const context = createContext();
148171

149172
const result = (await execute(context, job, state, {})) as TestState;
173+
t.deepEqual(result, { data: { a: 'a' } });
174+
175+
const debugLog = logger._find('debug', /did not return state/);
176+
t.truthy(debugLog);
150177

151-
t.assert(result === undefined);
178+
const warningLog = logger._find('warn', /No state was passed into operation/);
179+
t.truthy(warningLog);
152180
});
153181

154182
test.serial('jobs can mutate the original state', async (t) => {

packages/runtime/test/security.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// a suite of tests with various security concerns in mind
22
import test from 'ava';
33
import { createMockLogger } from '@openfn/logger';
4-
import type { ExecutionPlan, State } from '@openfn/lexicon';
4+
import type { ExecutionPlan } from '@openfn/lexicon';
55

66
import run from '../src/runtime';
77

@@ -64,12 +64,12 @@ test.serial(
6464
);
6565

6666
test.serial('jobs should not have access to global scope', async (t) => {
67-
const src = 'export default [() => globalThis.x]';
67+
const src = 'export default [() => ({x: globalThis.x, y: "some-val"})]';
6868
// @ts-ignore
6969
globalThis.x = 42;
7070

7171
const result: any = await run(src);
72-
t.falsy(result);
72+
t.deepEqual(result, { y: 'some-val' });
7373

7474
// @ts-ignore
7575
delete globalThis.x;
@@ -90,16 +90,17 @@ test.serial('jobs should be able to mutate global state', async (t) => {
9090
});
9191

9292
test.serial('jobs should each run in their own context', async (t) => {
93-
const src1 = 'export default [() => { globalThis.x = 1; return 1;}]';
94-
const src2 = 'export default [() => globalThis.x]';
93+
const src1 =
94+
'export default [() => { globalThis.x = 1; return { x: globalThis.x }}]';
95+
const src2 = 'export default [() => { return { x: globalThis.x }}]';
9596

9697
await run(src1);
9798

9899
const r1 = (await run(src1)) as any;
99-
t.is(r1, 1);
100+
t.deepEqual(r1, { x: 1 });
100101

101102
const r2 = (await run(src2)) as any;
102-
t.is(r2, undefined);
103+
t.deepEqual(r2, {});
103104
});
104105

105106
test.serial('jobs should not have a process object', async (t) => {

packages/ws-worker/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# ws-worker
22

3+
## 1.8.4
4+
5+
### Patch Changes
6+
7+
- Warn when an expression doesn't return state
8+
- Updated dependencies [1cbbba0]
9+
- @openfn/runtime@1.5.3
10+
- @openfn/engine-multi@1.4.3
11+
312
## 1.8.3
413

514
### Patch Changes

packages/ws-worker/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openfn/ws-worker",
3-
"version": "1.8.3",
3+
"version": "1.8.4",
44
"description": "A Websocket Worker to connect Lightning to a Runtime Engine",
55
"main": "dist/index.js",
66
"type": "module",

0 commit comments

Comments
 (0)