Skip to content

Commit 19c8dd3

Browse files
committed
feat: add function to calculate dynamic default Actor memory
1 parent 73ff70a commit 19c8dd3

File tree

6 files changed

+462
-3
lines changed

6 files changed

+462
-3
lines changed

package-lock.json

Lines changed: 108 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@
5959
"clone-deep": "^4.0.1",
6060
"commitlint": "^20.0.0",
6161
"eslint": "^9.24.0",
62-
"husky": "^9.1.4",
6362
"globals": "^16.0.0",
63+
"husky": "^9.1.4",
6464
"jest": "^29.7.0",
6565
"lerna": "^9.0.0",
6666
"lint-staged": "^16.0.0",
67+
"mathjs": "^15.1.0",
6768
"nock": "^14.0.0",
6869
"strip-ansi": "^6.0.0",
6970
"ts-jest": "^29.2.4",

packages/utilities/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './url_params_utils';
1010
export * from './code_hash_manager';
1111
export * from './hmac';
1212
export * from './storages';
13+
export * from './memory_calculator';
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { all, create, type EvalFunction } from 'mathjs/number';
2+
3+
import log from '@apify/log';
4+
5+
import type { LruCache } from '../../datastructures/src/lru_cache';
6+
7+
type ActorRunOptions = {
8+
build?: string;
9+
timeoutSecs?: number;
10+
memoryMbytes?: number; // probably no one will need it, but let's keep it consistent
11+
diskMbytes?: number; // probably no one will need it, but let's keep it consistent
12+
maxItems?: number;
13+
maxTotalChargeUsd?: number;
14+
restartOnError?: boolean;
15+
}
16+
17+
type MemoryEvaluationContext = {
18+
runOptions: ActorRunOptions;
19+
input: Record<string, unknown>;
20+
}
21+
22+
export const DEFAULT_MEMORY_MBYTES_MAX_CHARS = 1000;
23+
24+
/**
25+
* A Set of allowed keys from ActorRunOptions that can be used in
26+
* the {{variable}} syntax.
27+
*/
28+
const ALLOWED_RUN_OPTION_KEYS = new Set<keyof ActorRunOptions>([
29+
'build',
30+
'timeoutSecs',
31+
'memoryMbytes',
32+
'diskMbytes',
33+
'maxItems',
34+
'maxTotalChargeUsd',
35+
'restartOnError',
36+
]);
37+
38+
/**
39+
* Create a mathjs instance with all functions, then disable potentially dangerous ones.
40+
* Was taken from official mathjs security recommendations: https://mathjs.org/docs/expressions/security.html
41+
*/
42+
const math = create(all);
43+
const limitedEvaluate = math.evaluate;
44+
const limitedCompile = math.compile;
45+
46+
// Disable potentially dangerous functions
47+
math.import({
48+
// most important (hardly any functional impact)
49+
import() { throw new Error('Function import is disabled'); },
50+
createUnit() { throw new Error('Function createUnit is disabled'); },
51+
reviver() { throw new Error('Function reviver is disabled'); },
52+
53+
// extra (has functional impact)
54+
evaluate() { throw new Error('Function evaluate is disabled'); },
55+
parse() { throw new Error('Function parse is disabled'); },
56+
simplify() { throw new Error('Function simplify is disabled'); },
57+
derivative() { throw new Error('Function derivative is disabled'); },
58+
resolve() { throw new Error('Function resolve is disabled'); },
59+
}, { override: true });
60+
61+
/**
62+
* Safely retrieves a nested property from an object using a dot-notation string path.
63+
*
64+
* This is custom function designed to be injected into the math expression evaluator,
65+
* allowing expressions like `get(input, 'user.settings.memory', 512)`.
66+
*
67+
* @param obj The source object to search within.
68+
* @param path A dot-separated string representing the nested path (e.g., "input.payload.size").
69+
* @param defaultVal The value to return if the path is not found or the value is `null` or `undefined`.
70+
* @returns The retrieved value, or `defaultVal` if the path is unreachable.
71+
*/
72+
const customGetFunc = (obj: any, path: string, defaultVal?: number) => {
73+
return (path.split('.').reduce((current, key) => current?.[key], obj)) ?? defaultVal;
74+
};
75+
76+
/**
77+
* Rounds a number to the closest power of 2.
78+
* @param num The number to round.
79+
* @returns The closest power of 2.
80+
*/
81+
const roundToClosestPowerOf2 = (num: number): number | undefined => {
82+
// Handle 0 or negative values. The smallest power of 2 is 2^7 = 128.
83+
if (num <= 0) {
84+
throw new Error(`Calculated memory value must be a positive number, greater than 0, got: ${num}`);
85+
}
86+
if (typeof num !== 'number' || num <= 0 || Number.isNaN(num)) {
87+
log.warning('Failed to round number to a power of 2.', { num });
88+
throw new Error(`Failed to round number to a power of 2.`);
89+
}
90+
91+
const log2n = Math.log2(num);
92+
93+
const roundedLog = Math.round(log2n);
94+
95+
return 2 ** roundedLog;
96+
};
97+
98+
/**
99+
* Replaces `{{variable}}` placeholders in an expression string with `runOptions.variable`.
100+
*
101+
* This function also validates that the variable is one of the allowed 'runOptions' keys.
102+
*
103+
* @example
104+
* // Returns "runOptions.memoryMbytes + 1024"
105+
* preprocessDefaultMemoryExpression("{{memoryMbytes}} + 1024");
106+
*
107+
* @param defaultMemoryMbytes The raw string expression, e.g., "{{memoryMbytes}} * 2".
108+
* @returns A safe, processed expression for evaluation, e.g., "runOptions.memoryMbytes * 2".
109+
*/
110+
const preprocessDefaultMemoryExpression = (defaultMemoryMbytes: string): string => {
111+
// This regex captures the variable name inside {{...}}
112+
const variableRegex = /{{\s*([a-zA-Z0-9_]+)\s*}}/g;
113+
114+
const processedExpression = defaultMemoryMbytes.replace(
115+
variableRegex,
116+
(_, variableName: string) => {
117+
// Check if the captured variable name is in our allowlist
118+
if (!ALLOWED_RUN_OPTION_KEYS.has(variableName as keyof ActorRunOptions)) {
119+
throw new Error(
120+
`Invalid variable '{{${variableName}}}' in expression.`,
121+
);
122+
}
123+
124+
return `runOptions.${variableName}`;
125+
},
126+
);
127+
128+
return processedExpression;
129+
};
130+
131+
/**
132+
* Evaluates a dynamic string expression to calculate a memory value,
133+
* then rounds the result to the closest power of 2.
134+
*
135+
* This function provides a sandboxed environment for the expression and injects
136+
* a `get(obj, path, defaultVal)` helper to safely access properties
137+
* from the `context` (e.g., `input` and `runOptions`).
138+
*
139+
* @param defaultMemoryMbytes The string expression to evaluate (e.g., "get(input, 'size', 10) * 1024").
140+
* @param context The `MemoryEvaluationContext` (containing `input` and `runOptions`) available to the expression.
141+
* @returns The calculated memory value rounded to the closest power of 2,
142+
* or `undefined` if the expression fails, is non-numeric,
143+
* or results in a non-positive value.
144+
*/
145+
export const calculateDefaultMemoryFromExpression = (
146+
defaultMemoryMbytes: string,
147+
context: MemoryEvaluationContext,
148+
options: { cache: LruCache<EvalFunction> } | undefined = undefined,
149+
) => {
150+
if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_MAX_CHARS) {
151+
throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_MAX_CHARS} characters.`);
152+
}
153+
154+
// Replaces all occurrences of {{variable}} with runOptions.variable
155+
// e.g., "{{memoryMbytes}} + 1024" becomes "runOptions.memoryMbytes + 1024"
156+
const preProcessedExpression = preprocessDefaultMemoryExpression(defaultMemoryMbytes);
157+
158+
const preparedContext = {
159+
...context,
160+
get: customGetFunc,
161+
};
162+
163+
let finalResult: number | { entries: number[] };
164+
165+
if (options?.cache) {
166+
let compiledExpr = options.cache.get(preProcessedExpression);
167+
168+
if (!compiledExpr) {
169+
compiledExpr = limitedCompile(preProcessedExpression);
170+
options.cache.add(preProcessedExpression, compiledExpr!);
171+
}
172+
173+
finalResult = compiledExpr.evaluate(preparedContext);
174+
} else {
175+
finalResult = limitedEvaluate(preProcessedExpression, preparedContext);
176+
}
177+
178+
// Mathjs wraps multi-line expressions in an object, extract the last evaluated entry.
179+
if (finalResult && typeof finalResult === 'object' && 'entries' in finalResult) {
180+
const { entries } = finalResult;
181+
finalResult = entries[entries.length - 1];
182+
}
183+
184+
return roundToClosestPowerOf2(finalResult);
185+
};

0 commit comments

Comments
 (0)