Skip to content

Commit 641c460

Browse files
josephjclarkclaude
andcommitted
Engine: try to make calculating payload sizes more efficient (#1155)
* scaffolding: make ensure-payload-size async and add slots for two algorithms * first swing at async traversal This actually increases memory overhead * new traversal algorithm with queue, rather than recuion * add benchmarking util 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * tidying * optimisations Still doesn't get close * add streaming algo * update benchmark * remove traversal algorithm * tidy * tidy up * tidy docs * remove ignore * remove old traverse stats * typing * restore await * add a synchronous publish option important for process exit events * revert debug code --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5417e97 commit 641c460

11 files changed

Lines changed: 432 additions & 85 deletions

File tree

‎packages/engine-multi/memtest.js‎

Whitespace-only changes.

‎packages/engine-multi/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"@openfn/lexicon": "workspace:^",
2121
"@openfn/logger": "workspace:*",
2222
"@openfn/runtime": "workspace:*",
23-
"fast-safe-stringify": "^2.1.1"
23+
"fast-safe-stringify": "^2.1.1",
24+
"json-stream-stringify": "^3.1.6"
2425
},
2526
"devDependencies": {
2627
"@types/node": "^18.19.127",

‎packages/engine-multi/src/util/ensure-payload-size.ts‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { JsonStreamStringify } from 'json-stream-stringify';
2+
13
// This specifies which keys of an event payload to potentially redact
24
// if they are too big
35
const KEYS_TO_VERIFY = ['state', 'final_state', 'log'];
@@ -11,18 +13,22 @@ const replacements: Record<string, any> = {
1113
},
1214
};
1315

14-
export const verify = (value: any, limit_mb: number = 10) => {
16+
export const verify = async (
17+
value: any,
18+
limit_mb: number = 10,
19+
algo: 'stringify' | 'stream' = 'stringify'
20+
) => {
1521
if (value && !isNaN(limit_mb)) {
16-
let size_mb = 0;
17-
try {
18-
const str = typeof value === 'string' ? value : JSON.stringify(value);
19-
const size_bytes = Buffer.byteLength(str, 'utf8');
20-
size_mb = size_bytes / 1024 / 1024;
21-
} catch (e) {
22-
// do nothing
22+
const limitBytes = limit_mb * 1024 * 1024;
23+
24+
let sizeBytes: number;
25+
if (algo === 'stream') {
26+
sizeBytes = await calculateSizeStream(value, limitBytes);
27+
} else {
28+
sizeBytes = calculateSizeStringify(value);
2329
}
2430

25-
if (size_mb > limit_mb) {
31+
if (sizeBytes > limitBytes) {
2632
const e = new Error();
2733
// @ts-ignore
2834
e.name = 'PAYLOAD_TOO_LARGE';
@@ -32,12 +38,39 @@ export const verify = (value: any, limit_mb: number = 10) => {
3238
}
3339
};
3440

35-
export default (payload: any, limit_mb: number = 10) => {
41+
export const calculateSizeStringify = (value: any): number => {
42+
const str = typeof value === 'string' ? value : JSON.stringify(value);
43+
const size_bytes = Buffer.byteLength(str, 'utf8');
44+
return size_bytes;
45+
};
46+
47+
export const calculateSizeStream = async (
48+
value: any,
49+
limit?: number
50+
): Promise<number> => {
51+
let size_bytes = 0;
52+
53+
const stream = new JsonStreamStringify(value);
54+
55+
for await (const chunk of stream) {
56+
// Each chunk is a string token from the JSON output
57+
size_bytes += Buffer.byteLength(chunk, 'utf8');
58+
59+
if (limit !== undefined && size_bytes > limit) {
60+
break;
61+
}
62+
}
63+
stream.destroy();
64+
65+
return size_bytes;
66+
};
67+
68+
export default async (payload: any, limit_mb: number = 10) => {
3669
const newPayload = { ...payload };
3770

3871
for (const key of KEYS_TO_VERIFY) {
3972
try {
40-
verify(payload[key], limit_mb);
73+
await verify(payload[key], limit_mb);
4174
} catch (e) {
4275
Object.assign(newPayload[key], replacements[key] ?? replacements.default);
4376
newPayload.redacted = true;

‎packages/engine-multi/src/worker/thread/runtime.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,36 @@ type Options = {
4747
payloadLimits?: PayloadLimits;
4848
};
4949

50-
export const publish = (
50+
export const publish = async (
5151
type: string,
5252
payload: Omit<Event, 'threadId' | 'type'>
5353
) => {
5454
// Validate the size of every outgoing message
5555
// Redact any payloads that are too large
56-
// Use event-specific limit if available, otherwise use default
5756
const limit =
5857
payloadLimits?.[type as keyof PayloadLimits] ?? payloadLimits?.default;
59-
const safePayload = ensurePayloadSize(payload, limit);
60-
const x = {
58+
const safePayload = await ensurePayloadSize(payload, limit);
59+
60+
parentPort!.postMessage({
6161
type,
6262
threadId,
6363
processId,
6464
...safePayload,
65-
};
66-
parentPort!.postMessage(x);
65+
...payload,
66+
});
67+
};
68+
69+
// publishes without payload validation
70+
export const publishSync = async (
71+
type: string,
72+
payload: Omit<Event, 'threadId' | 'type'>
73+
) => {
74+
parentPort!.postMessage({
75+
type,
76+
threadId,
77+
processId,
78+
...payload,
79+
});
6780
};
6881

6982
const run = (task: string, args: any[], options: Options = {}) => {
@@ -90,7 +103,7 @@ const run = (task: string, args: any[], options: Options = {}) => {
90103
};
91104

92105
process.on('exit', (code) => {
93-
publish(ENGINE_REJECT_TASK, {
106+
publishSync(ENGINE_REJECT_TASK, {
94107
error: {
95108
name: 'ExitError',
96109
message: `Worker thread exited with code: ${code}`,

packages/engine-multi/test/memtest.ts renamed to packages/engine-multi/test/benchmarks/memtest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { getHeapStatistics } from 'node:v8';
55
import { createMockLogger } from '@openfn/logger';
66
import { randomUUID } from 'node:crypto';
7-
import createAPI from '../src/api';
7+
import createAPI from '../../src/api';
88

99
let completedCount = 0;
1010
const logger = createMockLogger();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* To measure memory usage:
3+
*
4+
* /usr/bin/time -v pnpm tsx test/payload.ts
5+
*/
6+
7+
import { verify } from '../../src/util/ensure-payload-size';
8+
9+
// this is 2.63mb of data
10+
// (I'm using using docs.json from adaptors)
11+
// @ts-ignore path may not exist
12+
import data from '../tmp/docs.json' with { type: 'json' };
13+
14+
// this is 16mb of data
15+
// import data from './tmp/mtg-StandardAtomic.json' with { type: 'json' };
16+
17+
const algo = process.argv[2] ?? 'stringify'
18+
19+
console.log('Running with algo', algo)
20+
21+
try {
22+
// @ts-ignore
23+
await verify(data, 0.1, algo as any)
24+
} catch(e) {
25+
console.log(e)
26+
// do nothing
27+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env bun
2+
// @ts-ignore
3+
import { $ } from 'bun';
4+
5+
const count = parseInt(process.argv[2] ?? '10');
6+
7+
async function run(algo: string) {
8+
// Run the command and capture both stdout and stderr
9+
const proc =
10+
await $`/usr/bin/time -v pnpm tsx test/payload.ts ${algo}`.quiet();
11+
12+
// time -v outputs to stderr
13+
const output = proc.stderr.toString();
14+
15+
// Extract user time (in seconds)
16+
const userTimeMatch = output.match(/User time \(seconds\): ([\d.]+)/);
17+
const userTime = userTimeMatch ? parseFloat(userTimeMatch[1]) : null;
18+
19+
// Extract maximum resident set size (in KB)
20+
const maxRssMatch = output.match(
21+
/Maximum resident set size \(kbytes\): (\d+)/
22+
);
23+
const maxRss = maxRssMatch ? parseInt(maxRssMatch[1]) : null;
24+
25+
// Create JSON output
26+
return {
27+
time: userTime,
28+
mem: maxRss,
29+
};
30+
}
31+
32+
async function bench(algo: string) {
33+
console.log(`\n==RUNNING BENCHMARK FOR ${algo.toUpperCase()}==\n`);
34+
35+
// one once to warm any caching - the very first run is often slowe
36+
await run(algo);
37+
38+
// Run 10 times and collect results
39+
const results = [];
40+
for (let i = 0; i < count; i++) {
41+
const result = await run(algo);
42+
results.push(result);
43+
console.log(`Run ${i + 1}: used ${result.mem}kb in ${result.time}ms`);
44+
}
45+
46+
// Calculate statistics
47+
const times = results.map((r) => r.time || 0);
48+
const mems = results.map((r) => r.mem || 0);
49+
50+
const avgTime = times.reduce((sum, t) => sum + t, 0) / times.length;
51+
const minTime = Math.min(...times);
52+
const maxTime = Math.max(...times);
53+
54+
const avgMem = mems.reduce((sum, m) => sum + m, 0) / mems.length;
55+
const minMem = Math.min(...mems);
56+
const maxMem = Math.max(...mems);
57+
58+
const stats = {
59+
time: { min: minTime, max: maxTime, avg: avgTime },
60+
mem: { min: minMem, max: maxMem, avg: avgMem },
61+
};
62+
63+
console.log(`\n${algo} results:`);
64+
console.log(
65+
` Time (ms): min=${minTime.toFixed(2)}, max=${maxTime.toFixed(
66+
2
67+
)}, avg=${avgTime.toFixed(2)}`
68+
);
69+
console.log(
70+
` Memory (MB): min=${(minMem / 1024).toFixed(2)}, max=${(
71+
maxMem / 1024
72+
).toFixed(2)}, avg=${(avgMem / 1024).toFixed(2)}`
73+
);
74+
console.log();
75+
76+
return stats;
77+
}
78+
79+
// @ts-ignore
80+
const stringifyStats = await bench('stringify');
81+
// @ts-ignore
82+
const streamStats = await bench('stream');
83+
84+
// Calculate percentage differences (traverse vs stringify baseline)
85+
function calcDiff(baseline: number, comparison: number) {
86+
return (((comparison - baseline) / baseline) * 100).toFixed(2);
87+
}
88+
89+
console.log('\n=== COMPARISON (stream vs stringify baseline) ===\n');
90+
91+
console.log('Time Performance:');
92+
console.log(
93+
` Min: ${calcDiff(stringifyStats.time.min, streamStats.time.min)}%`
94+
);
95+
console.log(
96+
` Max: ${calcDiff(stringifyStats.time.max, streamStats.time.max)}%`
97+
);
98+
console.log(
99+
` Avg: ${calcDiff(stringifyStats.time.avg, streamStats.time.avg)}%`
100+
);
101+
102+
console.log('\nMemory Usage:');
103+
console.log(` Min: ${calcDiff(stringifyStats.mem.min, streamStats.mem.min)}%`);
104+
console.log(` Max: ${calcDiff(stringifyStats.mem.max, streamStats.mem.max)}%`);
105+
console.log(` Avg: ${calcDiff(stringifyStats.mem.avg, streamStats.mem.avg)}%`);
106+
107+
console.log('\n(Negative values mean stream is better)');

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ test.serial('redact final state if it exceeds the payload limit', (t) => {
518518

519519
const expression = `
520520
export default [(state) => {
521-
state.data = new Array(1024 * 1024).fill('a')
521+
state.data = new Array(1024 * 512).fill('a').join('')
522522
return state;
523523
}]`;
524524

@@ -528,7 +528,7 @@ export default [(state) => {
528528
},
529529
]);
530530
const options = {
531-
payloadLimitMb: 0.5,
531+
payloadLimitMb: 0.1,
532532
};
533533

534534
api

0 commit comments

Comments
 (0)