Skip to content

Commit a23f386

Browse files
committed
refactor: clean up
1 parent 3b62b9f commit a23f386

File tree

9 files changed

+94
-75
lines changed

9 files changed

+94
-75
lines changed
File renamed without changes.

packages/math-utils/package.json renamed to packages/actor-memory-expression/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@apify/math-utils",
2+
"name": "@apify/actor-memory-expression",
33
"version": "0.0.1",
44
"description": "Mathematical and numerical utility functions.",
55
"main": "./dist/cjs/index.cjs",
File renamed without changes.

packages/math-utils/src/memory_calculator.ts renamed to packages/actor-memory-expression/src/memory_calculator.ts

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,12 @@ import {
2121
import { ACTOR_LIMITS } from '@apify/consts';
2222

2323
import type { LruCache } from '../../datastructures/src/lru_cache';
24+
import type { ActorRunOptions, MemoryEvaluationContext } from './types.js';
2425

25-
type ActorRunOptions = {
26-
build?: string;
27-
timeoutSecs?: number;
28-
memoryMbytes?: number; // probably no one will need it, but let's keep it consistent
29-
diskMbytes?: number; // probably no one will need it, but let's keep it consistent
30-
maxItems?: number;
31-
maxTotalChargeUsd?: number;
32-
restartOnError?: boolean;
33-
}
34-
35-
type MemoryEvaluationContext = {
36-
runOptions: ActorRunOptions;
37-
input: Record<string, unknown>;
38-
}
39-
40-
export const DEFAULT_MEMORY_MBYTES_MAX_CHARS = 1000;
26+
// In theory, users could create expressions longer than 1000 characters,
27+
// but in practice, it's unlikely anyone would need that much complexity.
28+
// Later we can increase this limit if needed.
29+
export const DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH = 1000;
4130

4231
/**
4332
* A Set of allowed keys from ActorRunOptions that can be used in
@@ -58,14 +47,18 @@ const ALLOWED_RUN_OPTION_KEYS = new Set<keyof ActorRunOptions>([
5847
* MathJS security recommendations: https://mathjs.org/docs/expressions/security.html
5948
*/
6049
const math = create({
50+
// expression dependencies
51+
// Required for compiling and evaluating root expressions.
52+
// We disable it below to prevent users from calling `evaluate()` inside their expressions.
53+
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
54+
compileDependencies,
55+
evaluateDependencies,
56+
6157
// arithmetic dependencies
6258
addDependencies,
6359
subtractDependencies,
6460
multiplyDependencies,
6561
divideDependencies,
66-
// expression dependencies
67-
compileDependencies,
68-
evaluateDependencies,
6962
// statistics dependencies
7063
maxDependencies,
7164
minDependencies,
@@ -77,8 +70,7 @@ const math = create({
7770
// without that dependency 'null ?? 5', won't work
7871
nullishDependencies,
7972
});
80-
const limitedEvaluate = math.evaluate;
81-
const limitedCompile = math.compile;
73+
const { compile } = math;
8274

8375
// Disable potentially dangerous functions
8476
math.import({
@@ -88,6 +80,8 @@ math.import({
8880
reviver() { throw new Error('Function reviver is disabled'); },
8981

9082
// extra (has functional impact)
83+
// We disable evaluate to prevent users from calling it inside their expressions.
84+
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
9185
evaluate() { throw new Error('Function evaluate is disabled'); },
9286
parse() { throw new Error('Function parse is disabled'); },
9387
simplify() { throw new Error('Function simplify is disabled'); },
@@ -118,7 +112,7 @@ const customGetFunc = (obj: any, path: string, defaultVal?: number) => {
118112
*/
119113
const roundToClosestPowerOf2 = (num: number): number | undefined => {
120114
if (typeof num !== 'number' || Number.isNaN(num)) {
121-
throw new Error(`Failed to round number to a power of 2.`);
115+
throw new Error(`Calculated memory value is not a valid number: ${num}.`);
122116
}
123117

124118
// Handle 0 or negative values.
@@ -135,8 +129,14 @@ const roundToClosestPowerOf2 = (num: number): number | undefined => {
135129
};
136130

137131
/**
138-
* Replaces `{{variable}}` placeholders in an expression string with the variable name.
139-
* Enforces strict validation to allow `{{input.*}}` paths or whitelisted `{{runOptions.*}}` keys.
132+
* Replaces all `{{variable}}` placeholders in an expression into direct
133+
* property access (e.g. `{{runOptions.memoryMbytes}}` → `runOptions.memoryMbytes`).
134+
*
135+
* Only variables starting with `input.` or whitelisted `runOptions.` keys are allowed.
136+
* All `input.*` values are accepted, while `runOptions.*` are validated (only 7 variables - ALLOWED_RUN_OPTION_KEYS).
137+
*
138+
* Note: this approach allows developers to use a consistent double-brace
139+
* syntax (`{{runOptions.timeoutSecs}}`) across the platform.
140140
*
141141
* @example
142142
* // Returns "runOptions.memoryMbytes + 1024"
@@ -145,26 +145,25 @@ const roundToClosestPowerOf2 = (num: number): number | undefined => {
145145
* @param defaultMemoryMbytes The raw string expression, e.g., "{{runOptions.memoryMbytes}} * 2".
146146
* @returns A safe, processed expression for evaluation, e.g., "runOptions.memoryMbytes * 2".
147147
*/
148-
const preprocessRunMemoryExpression = (defaultMemoryMbytes: string): string => {
148+
const processTemplateVariables = (defaultMemoryMbytes: string): string => {
149149
const variableRegex = /{{\s*([a-zA-Z0-9_.]+)\s*}}/g;
150150

151151
const processedExpression = defaultMemoryMbytes.replace(
152152
variableRegex,
153153
(_, variableName: string) => {
154-
// 1. Validate that the variable starts with either 'input.' or 'runOptions.'
155154
if (!variableName.startsWith('runOptions.') && !variableName.startsWith('input.')) {
156155
throw new Error(
157156
`Invalid variable '{{${variableName}}}' in expression. Variables must start with 'input.' or 'runOptions.'.`,
158157
);
159158
}
160159

161-
// 2. Check if the variable is accessing input (e.g. {{input.someValue}})
162-
// We do not validate the specific property name because input is dynamic.
160+
// 1. Check if the variable is accessing input (e.g. {{input.someValue}})
161+
// We do not validate the specific property name because `input` is dynamic.
163162
if (variableName.startsWith('input.')) {
164163
return variableName;
165164
}
166165

167-
// 3. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys.
166+
// 2. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys.
168167
if (variableName.startsWith('runOptions.')) {
169168
const key = variableName.slice('runOptions.'.length);
170169
if (!ALLOWED_RUN_OPTION_KEYS.has(key as keyof ActorRunOptions)) {
@@ -185,11 +184,26 @@ const preprocessRunMemoryExpression = (defaultMemoryMbytes: string): string => {
185184
return processedExpression;
186185
};
187186

187+
const getCompiledExpression = (expression: string, cache: LruCache<EvalFunction> | undefined): EvalFunction => {
188+
if (!cache) {
189+
return compile(expression);
190+
}
191+
192+
let compiledExpression = cache.get(expression);
193+
194+
if (!compiledExpression) {
195+
compiledExpression = compile(expression);
196+
cache.add(expression, compiledExpression!);
197+
}
198+
199+
return compiledExpression;
200+
};
201+
188202
/**
189203
* Evaluates a dynamic memory expression string using the provided context.
190204
* Result is rounded to the closest power of 2 and clamped within allowed limits.
191205
*
192-
* @param defaultMemoryMbytes The string expression to evaluate (e.g., "get(input, 'size', 10) * 1024").
206+
* @param defaultMemoryMbytes The string expression to evaluate (e.g., `get(input, 'urls.length', 10) * 1024` for `input = { urls: ['url1', 'url2'] }`).
193207
* @param context The `MemoryEvaluationContext` (containing `input` and `runOptions`) available to the expression.
194208
* @returns The calculated memory value rounded to the closest power of 2 clamped within allowed limits.
195209
*/
@@ -198,33 +212,22 @@ export const calculateRunDynamicMemory = (
198212
context: MemoryEvaluationContext,
199213
options: { cache: LruCache<EvalFunction> } | undefined = undefined,
200214
) => {
201-
if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_MAX_CHARS) {
202-
throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_MAX_CHARS} characters.`);
215+
if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH) {
216+
throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`);
203217
}
204218

205219
// Replaces all occurrences of {{variable}} with variable
206220
// e.g., "{{runOptions.memoryMbytes}} + 1024" becomes "runOptions.memoryMbytes + 1024"
207-
const preprocessedExpression = preprocessRunMemoryExpression(defaultMemoryMbytes);
221+
const preprocessedExpression = processTemplateVariables(defaultMemoryMbytes);
208222

209223
const preparedContext = {
210224
...context,
211225
get: customGetFunc,
212226
};
213227

214-
let finalResult: number | { entries: number[] };
215-
216-
if (options?.cache) {
217-
let compiledExpr = options.cache.get(preprocessedExpression);
228+
const compiledExpression = getCompiledExpression(preprocessedExpression, options?.cache);
218229

219-
if (!compiledExpr) {
220-
compiledExpr = limitedCompile(preprocessedExpression);
221-
options.cache.add(preprocessedExpression, compiledExpr!);
222-
}
223-
224-
finalResult = compiledExpr.evaluate(preparedContext);
225-
} else {
226-
finalResult = limitedEvaluate(preprocessedExpression, preparedContext);
227-
}
230+
let finalResult: number | { entries: number[] } = compiledExpression.evaluate(preparedContext);
228231

229232
// Mathjs wraps multi-line expressions in an object, so we need to extract the last entry.
230233
// Note: one-line expressions return a number directly.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type ActorRunOptions = {
2+
build?: string;
3+
timeoutSecs?: number;
4+
memoryMbytes?: number; // probably no one will need it, but let's keep it consistent
5+
diskMbytes?: number; // probably no one will need it, but let's keep it consistent
6+
maxItems?: number;
7+
maxTotalChargeUsd?: number;
8+
restartOnError?: boolean;
9+
}
10+
11+
export type MemoryEvaluationContext = {
12+
runOptions: ActorRunOptions;
13+
input: Record<string, unknown>;
14+
}
File renamed without changes.

test/memory_calculator.test.ts

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { EvalFunction } from 'mathjs';
22

3+
import { calculateRunDynamicMemory, DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH } from '@apify/actor-memory-expression';
34
import { LruCache } from '@apify/datastructures';
4-
import { calculateRunDynamicMemory, DEFAULT_MEMORY_MBYTES_MAX_CHARS } from '@apify/math-utils';
55

66
describe('calculateDefaultMemoryFromExpression', () => {
77
const emptyContext = { input: {}, runOptions: {} };
@@ -59,62 +59,64 @@ describe('calculateDefaultMemoryFromExpression', () => {
5959
runOptions: { timeoutSecs: 60, memoryMbytes: 512 },
6060
};
6161

62+
// Note: all results are rounded to the closest power of 2 and clamped within limits.
6263
const cases = [
63-
{ expression: '5 + 5', desc: '+ allowed' },
64-
{ expression: '6 - 5', desc: '- allowed' },
65-
{ expression: '5 / 5', desc: '/ allowed' },
66-
{ expression: '5 * 5', desc: '* allowed' },
67-
{ expression: 'max(1, 2, 3)', desc: 'max() allowed' },
68-
{ expression: 'min(1, 2, 3)', desc: 'min() allowed' },
69-
{ expression: '(true and false) ? 0 : 5', desc: 'and allowed' },
70-
{ expression: '(true or false) ? 5 : 0', desc: 'or allowed' },
71-
{ expression: '(true xor false) ? 5 : 0', desc: 'xor allowed' },
72-
{ expression: 'not(false) ? 5 : 0', desc: 'not allowed' },
73-
{ expression: 'null ?? 256', desc: 'nullish coalescing allowed' },
74-
{ expression: 'a = 5', desc: 'variable assignment' },
64+
{ expression: '128 + 5', result: 128, name: '+' },
65+
{ expression: '128 - 5', result: 128, name: '-' },
66+
{ expression: '128 / 5', result: 128, name: '/' },
67+
{ expression: '128 * 5', result: 512, name: '*' },
68+
{ expression: 'max(128, 2, 3)', result: 128, name: 'max()' },
69+
{ expression: 'min(128, 512, 1024)', result: 128, name: 'min()' },
70+
{ expression: '(true and false) ? 0 : 128', result: 128, name: 'and' },
71+
{ expression: '(true or false) ? 128 : 0', result: 128, name: 'or' },
72+
{ expression: '(true xor false) ? 128 : 0', result: 128, name: 'xor' },
73+
{ expression: 'not(false) ? 128 : 0', result: 128, name: 'not' },
74+
{ expression: 'null ?? 256', result: 256, name: 'nullish coalescing' },
75+
{ expression: 'a = 128', result: 128, name: '=' },
7576
];
7677

7778
it.each(cases)(
78-
'$desc',
79-
({ expression }) => {
79+
`supports operation '$name'`,
80+
({ expression, result }) => {
8081
// in case operation is not supported, mathjs will throw
81-
expect(calculateRunDynamicMemory(expression, context)).toBeDefined();
82+
// we round the result to the closest power of 2 and clamp within limits.
83+
expect(calculateRunDynamicMemory(expression, context)).toBe(result);
8284
},
8385
);
8486
});
8587
});
8688

87-
describe('Preprocessing with {{variable}}', () => {
88-
it('should throw error if variable doesn\'t start with .runOptions or .input', () => {
89+
describe('Template {{variables}} support', () => {
90+
it('should throw error if variable doesn\'t start with runOptions. or input.', () => {
8991
const context = { input: {}, runOptions: { memoryMbytes: 16 } };
90-
const expr = '{{unexistingVariable}} * 1024';
92+
const expr = '{{nonexistentVariable}} * 1024';
9193
expect(() => calculateRunDynamicMemory(expr, context))
92-
.toThrow(`Invalid variable '{{unexistingVariable}}' in expression. Variables must start with 'input.' or 'runOptions.'.`);
94+
.toThrow(`Invalid variable '{{nonexistentVariable}}' in expression. Variables must start with 'input.' or 'runOptions.'.`);
9395
});
9496

95-
it('correctly replaces {{runOptions.variable}} with valid runOptions.variable', () => {
97+
it('correctly evaluates valid runOptions property', () => {
9698
const context = { input: {}, runOptions: { memoryMbytes: 16 } };
9799
const expr = '{{runOptions.memoryMbytes}} * 1024';
98100
const result = calculateRunDynamicMemory(expr, context);
99101
expect(result).toBe(16384);
100102
});
101103

102-
it('correctly replaces {{input.variable}} with valid input.variable', () => {
104+
it('correctly evaluates input property', () => {
103105
const context = { input: { value: 16 }, runOptions: { } };
104106
const expr = '{{input.value}} * 1024';
105107
const result = calculateRunDynamicMemory(expr, context);
106108
expect(result).toBe(16384);
107109
});
108110

109-
it('should throw error if runOptions variable is invalid', () => {
111+
it('should throw error if runOptions property is not supported', () => {
110112
const context = { input: { value: 16 }, runOptions: { } };
111113
const expr = '{{runOptions.customVariable}} * 1024';
112114
expect(() => calculateRunDynamicMemory(expr, context))
113115
.toThrow(`Invalid variable '{{runOptions.customVariable}}' in expression. Only the following runOptions are allowed:`);
114116
});
115117
});
116118

117-
describe('Rounding Logic', () => {
119+
describe('Rounding logic', () => {
118120
it('should round down (e.g., 10240 -> 8192)', () => {
119121
// 2^13 = 8192, 2^14 = 16384.
120122
const result = calculateRunDynamicMemory('10240', emptyContext);
@@ -140,9 +142,9 @@ describe('calculateDefaultMemoryFromExpression', () => {
140142

141143
describe('Invalid/Error Handling', () => {
142144
it('should throw an error if expression length is greater than DEFAULT_MEMORY_MBYTES_MAX_CHARS', () => {
143-
const expr = '1'.repeat(DEFAULT_MEMORY_MBYTES_MAX_CHARS + 1); // Assuming max length is 1000
145+
const expr = '1'.repeat(DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH + 1); // Assuming max length is 1000
144146
expect(() => calculateRunDynamicMemory(expr, emptyContext))
145-
.toThrow(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_MAX_CHARS} characters.`);
147+
.toThrow(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`);
146148
});
147149

148150
it('should throw an error for invalid syntax', () => {
@@ -160,11 +162,11 @@ describe('calculateDefaultMemoryFromExpression', () => {
160162
});
161163

162164
it('should throw error if result is NaN', () => {
163-
expect(() => calculateRunDynamicMemory('0 / 0', emptyContext)).toThrow('Failed to round number to a power of 2.');
165+
expect(() => calculateRunDynamicMemory('0 / 0', emptyContext)).toThrow('Calculated memory value is not a valid number: NaN.');
164166
});
165167

166168
it('should throw error if result is a non-numeric (string)', () => {
167-
expect(() => calculateRunDynamicMemory("'hello'", emptyContext)).toThrow('Failed to round number to a power of 2.');
169+
expect(() => calculateRunDynamicMemory("'hello'", emptyContext)).toThrow('Calculated memory value is not a valid number: hello.');
168170
});
169171

170172
it('should throw error when disabled functionality of MathJS is used', () => {

0 commit comments

Comments
 (0)