Skip to content

Commit a77d606

Browse files
authored
Merge branch 'master' into feat/custom-error-message
2 parents 075f07b + 7e17544 commit a77d606

File tree

13 files changed

+1777
-1768
lines changed

13 files changed

+1777
-1768
lines changed

.github/workflows/publish_to_npm.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
name: Publish to NPM
1818
runs-on: ubuntu-latest
1919
steps:
20-
- uses: actions/checkout@v5
20+
- uses: actions/checkout@v6
2121
with:
2222
ref: ${{ inputs.ref }}
2323
token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }}

.github/workflows/test_and_release.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
node-version: [ 16, 18, 20, 22 ]
1919

2020
steps:
21-
- uses: actions/checkout@v5
21+
- uses: actions/checkout@v6
2222
- name: Use Node.js ${{ matrix.node-version }}
2323
uses: actions/setup-node@v6
2424
with:
@@ -41,7 +41,7 @@ jobs:
4141
runs-on: ubuntu-latest
4242

4343
steps:
44-
- uses: actions/checkout@v5
44+
- uses: actions/checkout@v6
4545
- name: Use Node.js 20
4646
uses: actions/setup-node@v6
4747
with:
@@ -67,7 +67,7 @@ jobs:
6767
runs-on: ubuntu-latest
6868

6969
steps:
70-
- uses: actions/checkout@v5
70+
- uses: actions/checkout@v6
7171
- name: Use Node.js 20
7272
uses: actions/setup-node@v6
7373
with:
@@ -87,7 +87,7 @@ jobs:
8787
needs: [ test, build, lint ]
8888
runs-on: ubuntu-latest
8989
steps:
90-
- uses: actions/checkout@v5
90+
- uses: actions/checkout@v6
9191
with:
9292
fetch-depth: 0 # we need to pull everything to allow lerna to detect what packages changed
9393
- uses: actions/setup-node@v6

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ See the changelogs of each package:
88

99
package | version | changelog
1010
--------|---------|----------
11+
`@apify/actor-memory-expression` | 0.1.1 | [CHANGELOG](./packages/actor-memory-expression/CHANGELOG.md)
1112
`@apify/consts` | 2.47.0 | [CHANGELOG](./packages/consts/CHANGELOG.md)
1213
`@apify/datastructures` | 2.0.3 | [CHANGELOG](./packages/datastructures/CHANGELOG.md)
1314
`@apify/dummy-package-for-testing` | 2.2.0 | [CHANGELOG](./packages/dummy/CHANGELOG.md)

package-lock.json

Lines changed: 1173 additions & 1763 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Change Log
2+
3+
All notable changes to this project will be documented in this file.
4+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5+
6+
## [0.1.1](https://github.com/apify/apify-shared-js/compare/@apify/[email protected]...@apify/[email protected]) (2025-11-27)
7+
8+
**Note:** Version bump only for package @apify/actor-memory-expression
9+
10+
11+
12+
13+
14+
# 0.1.0 (2025-11-25)
15+
16+
17+
### Features
18+
19+
* add function to calculate dynamic default Actor memory ([#570](https://github.com/apify/apify-shared-js/issues/570)) ([f3a97bf](https://github.com/apify/apify-shared-js/commit/f3a97bf86b14cc548c958a21a75720e4e4724b65))
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@apify/actor-memory-expression",
3+
"version": "0.1.1",
4+
"description": "Utility to evaluate dynamic memory expressions for Apify actors.",
5+
"main": "./dist/cjs/index.cjs",
6+
"module": "./dist/esm/index.mjs",
7+
"typings": "./dist/cjs/index.d.ts",
8+
"exports": {
9+
".": {
10+
"import": {
11+
"types": "./dist/esm/index.d.mts",
12+
"default": "./dist/esm/index.mjs"
13+
},
14+
"require": {
15+
"types": "./dist/cjs/index.d.ts",
16+
"default": "./dist/cjs/index.cjs"
17+
}
18+
}
19+
},
20+
"keywords": [
21+
"apify"
22+
],
23+
"author": {
24+
"name": "Apify",
25+
"email": "[email protected]",
26+
"url": "https://apify.com"
27+
},
28+
"contributors": [
29+
"Jan Curn <[email protected]>",
30+
"Marek Trunkát <[email protected]>"
31+
],
32+
"license": "Apache-2.0",
33+
"repository": {
34+
"type": "git",
35+
"url": "git+https://github.com/apify/apify-shared-js"
36+
},
37+
"bugs": {
38+
"url": "https://github.com/apify/apify-shared-js/issues"
39+
},
40+
"homepage": "https://apify.com",
41+
"scripts": {
42+
"build": "npm run clean && npm run compile && npm run copy",
43+
"clean": "rimraf ./dist",
44+
"compile": "tsup",
45+
"copy": "ts-node -T ../../scripts/copy.ts"
46+
},
47+
"publishConfig": {
48+
"access": "public"
49+
},
50+
"dependencies": {
51+
"@apify/consts": "^2.47.0",
52+
"@apify/log": "^2.5.26",
53+
"mathjs": "^15.1.0"
54+
}
55+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './memory_calculator';
2+
export * from './types';
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// MathJS bundle with only numbers is ~2x smaller than the default one.
2+
import {
3+
addDependencies,
4+
andDependencies,
5+
compileDependencies,
6+
create,
7+
divideDependencies,
8+
evaluateDependencies,
9+
maxDependencies,
10+
minDependencies,
11+
multiplyDependencies,
12+
notDependencies,
13+
// @ts-expect-error nullishDependencies is not declared in types. https://github.com/josdejong/mathjs/issues/3597
14+
nullishDependencies,
15+
orDependencies,
16+
subtractDependencies,
17+
xorDependencies,
18+
} from 'mathjs';
19+
20+
import { ACTOR_LIMITS } from '@apify/consts';
21+
22+
import type { ActorRunOptions, CompilationCache, CompilationResult, MemoryEvaluationContext } from './types.js';
23+
24+
// In theory, users could create expressions longer than 1000 characters,
25+
// but in practice, it's unlikely anyone would need that much complexity.
26+
// Later we can increase this limit if needed.
27+
export const DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH = 1000;
28+
29+
/**
30+
* A Set of allowed keys from ActorRunOptions that can be used in
31+
* the {{runOptions.variable}} syntax.
32+
*/
33+
const ALLOWED_RUN_OPTION_KEYS = new Set<keyof ActorRunOptions>([
34+
'build',
35+
'timeoutSecs',
36+
'memoryMbytes',
37+
'diskMbytes',
38+
'maxItems',
39+
'maxTotalChargeUsd',
40+
'restartOnError',
41+
]);
42+
43+
/**
44+
* Create a mathjs instance with selected dependencies, then disable potentially dangerous ones.
45+
* MathJS security recommendations: https://mathjs.org/docs/expressions/security.html
46+
*/
47+
const math = create({
48+
// expression dependencies
49+
// Required for compiling and evaluating root expressions.
50+
// We disable it below to prevent users from calling `evaluate()` inside their expressions.
51+
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
52+
compileDependencies,
53+
evaluateDependencies,
54+
55+
// arithmetic dependencies
56+
addDependencies,
57+
subtractDependencies,
58+
multiplyDependencies,
59+
divideDependencies,
60+
// statistics dependencies
61+
maxDependencies,
62+
minDependencies,
63+
// logical dependencies
64+
andDependencies,
65+
notDependencies,
66+
orDependencies,
67+
xorDependencies,
68+
// without that dependency 'null ?? 5', won't work
69+
nullishDependencies,
70+
});
71+
const { compile } = math;
72+
73+
// Disable potentially dangerous functions
74+
math.import({
75+
// We disable evaluate to prevent users from calling it inside their expressions.
76+
// For example: defaultMemoryMbytes = "evaluate('2 + 2')"
77+
evaluate() { throw new Error('Function evaluate is disabled.'); },
78+
compile() { throw new Error('Function compile is disabled.'); },
79+
// We need to disable it, because compileDependencies imports parseDependencies.
80+
parse() { throw new Error('Function parse is disabled.'); },
81+
}, { override: true });
82+
83+
/**
84+
* Safely retrieves a nested property from an object using a dot-notation string path.
85+
*
86+
* This is custom function designed to be injected into the math expression evaluator,
87+
* allowing expressions like `get(input, 'user.settings.memory', 512)` or `get(input, 'startUrls.length', 10)` to get array length.
88+
*
89+
* @param obj The source object to search within.
90+
* @param path A dot-separated string representing the nested path (e.g., "input.payload.size").
91+
* @param defaultVal The value to return if the path is not found or the value is `null` or `undefined`.
92+
* @returns The retrieved value, or `defaultVal` if the path is unreachable.
93+
*/
94+
const customGetFunc = (obj: any, path: string, defaultVal?: number) => {
95+
return (path.split('.').reduce((current, key) => current?.[key], obj)) ?? defaultVal;
96+
};
97+
98+
/**
99+
* Rounds a number to the closest power of 2.
100+
* The result is clamped to the allowed range (ACTOR_LIMITS.MIN_RUN_MEMORY_MBYTES - ACTOR_LIMITS.MAX_RUN_MEMORY_MBYTES).
101+
* @param num The number to round.
102+
* @returns The closest power of 2 within min/max range.
103+
*/
104+
const roundToClosestPowerOf2 = (num: number): number => {
105+
if (typeof num !== 'number' || Number.isNaN(num) || !Number.isFinite(num)) {
106+
throw new Error(`Calculated memory value is not a valid number: ${num}.`);
107+
}
108+
109+
// Handle 0 or negative values.
110+
if (num <= 0) {
111+
throw new Error(`Calculated memory value must be a positive number, greater than 0, got: ${num}.`);
112+
}
113+
114+
const log2n = Math.log2(num);
115+
116+
const roundedLog = Math.round(log2n);
117+
const result = 2 ** roundedLog;
118+
119+
return Math.max(ACTOR_LIMITS.MIN_RUN_MEMORY_MBYTES, Math.min(result, ACTOR_LIMITS.MAX_RUN_MEMORY_MBYTES));
120+
};
121+
122+
/**
123+
* Replaces all `{{variable}}` placeholders in an expression into direct
124+
* property access (e.g. `{{runOptions.memoryMbytes}}` → `runOptions.memoryMbytes`).
125+
*
126+
* All `input.*` values are accepted, while `runOptions.*` are validated (7 variables from ALLOWED_RUN_OPTION_KEYS).
127+
*
128+
* Note: While not really needed for Math.js, this approach allows developers
129+
* to use a consistent double-brace templating syntax `{{runOptions.timeoutSecs}}`
130+
* across the Apify platform. We also want to avoid compiling the expression with the
131+
* actual values as that would make caching less effective.
132+
*
133+
* @example
134+
* // Returns "runOptions.memoryMbytes + 1024"
135+
* preprocessDefaultMemoryExpression("{{runOptions.memoryMbytes}} + 1024");
136+
*
137+
* @param defaultMemoryMbytes The raw string expression, e.g., "{{runOptions.memoryMbytes}} * 2".
138+
* @returns A safe, processed expression for evaluation, e.g., "runOptions.memoryMbytes * 2".
139+
*/
140+
const processTemplateVariables = (defaultMemoryMbytes: string): string => {
141+
const variableRegex = /{{\s*([a-zA-Z0-9_.]+)\s*}}/g;
142+
143+
const processedExpression = defaultMemoryMbytes.replace(
144+
variableRegex,
145+
(_, variableName: string) => {
146+
// 1. Check if the variable is accessing input (e.g. {{input.someValue}})
147+
// We do not validate the specific property name because `input` is dynamic.
148+
if (variableName.startsWith('input.')) {
149+
return variableName;
150+
}
151+
152+
// 2. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys.
153+
if (variableName.startsWith('runOptions.')) {
154+
const key = variableName.slice('runOptions.'.length);
155+
if (!ALLOWED_RUN_OPTION_KEYS.has(key as keyof ActorRunOptions)) {
156+
throw new Error(
157+
`Invalid variable '{{${variableName}}}' in expression. Only the following runOptions are allowed: ${Array.from(ALLOWED_RUN_OPTION_KEYS).map((k) => `runOptions.${k}`).join(', ')}.`,
158+
);
159+
}
160+
return variableName;
161+
}
162+
163+
// 3. Throw error for unrecognized variables (e.g. {{someVariable}})
164+
throw new Error(
165+
`Invalid variable '{{${variableName}}}' in expression.`,
166+
);
167+
},
168+
);
169+
170+
return processedExpression;
171+
};
172+
173+
/*
174+
* Retrieves a compiled expression from the cache or compiles it if not present.
175+
*
176+
* @param expression The expression string to compile.
177+
* @param cache An optional cache to store/retrieve compiled expressions.
178+
* @returns The compiled CompilationResult.
179+
*/
180+
const getCompiledExpression = async (expression: string, cache: CompilationCache | undefined): Promise<CompilationResult> => {
181+
if (!cache) {
182+
return compile(expression);
183+
}
184+
185+
let compiledExpression = await cache.get(expression);
186+
187+
if (!compiledExpression) {
188+
compiledExpression = compile(expression);
189+
await cache.set(expression, compiledExpression!);
190+
}
191+
192+
return compiledExpression;
193+
};
194+
195+
/**
196+
* Evaluates a dynamic memory expression string using the provided context.
197+
* Result is rounded to the closest power of 2 and clamped within allowed limits.
198+
*
199+
* @param defaultMemoryMbytes The string expression to evaluate (e.g., `get(input, 'urls.length', 10) * 1024` for `input = { urls: ['url1', 'url2'] }`).
200+
* @param context The `MemoryEvaluationContext` (containing `input` and `runOptions`) available to the expression.
201+
* @param options.cache Optional synchronous cache. Since compiled functions cannot be saved to a database/Redis, they are kept in local memory.
202+
* @returns The calculated memory value rounded to the closest power of 2 and clamped within allowed limits.
203+
*/
204+
export const calculateRunDynamicMemory = async (
205+
defaultMemoryMbytes: string,
206+
context: MemoryEvaluationContext,
207+
options: { cache: CompilationCache } | undefined = undefined,
208+
) => {
209+
if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH) {
210+
throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`);
211+
}
212+
213+
// Replaces all occurrences of {{variable}} with variable
214+
// e.g., "{{runOptions.memoryMbytes}} + 1024" becomes "runOptions.memoryMbytes + 1024"
215+
const preprocessedExpression = processTemplateVariables(defaultMemoryMbytes);
216+
217+
const preparedContext = {
218+
...context,
219+
get: customGetFunc,
220+
};
221+
222+
const compiledExpression = await getCompiledExpression(preprocessedExpression, options?.cache);
223+
224+
let finalResult: number | { entries: number[] } = compiledExpression.evaluate(preparedContext);
225+
226+
// Mathjs wraps multi-line expressions in an object, so we need to extract the last entry.
227+
// Note: one-line expressions return a number directly.
228+
if (finalResult && typeof finalResult === 'object' && 'entries' in finalResult) {
229+
const { entries } = finalResult;
230+
finalResult = entries[entries.length - 1];
231+
}
232+
233+
return roundToClosestPowerOf2(finalResult);
234+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { EvalFunction } from 'mathjs';
2+
3+
export type ActorRunOptions = {
4+
build?: string;
5+
timeoutSecs?: number;
6+
memoryMbytes?: number; // probably no one will need it, but let's keep it consistent
7+
diskMbytes?: number; // probably no one will need it, but let's keep it consistent
8+
maxItems?: number;
9+
maxTotalChargeUsd?: number;
10+
restartOnError?: boolean;
11+
}
12+
13+
export type MemoryEvaluationContext = {
14+
runOptions: ActorRunOptions;
15+
input: Record<string, unknown>;
16+
}
17+
18+
export type CompilationCache = {
19+
get: (expression: string) => Promise<EvalFunction | null>;
20+
set: (expression: string, compilationResult: EvalFunction) => Promise<void>;
21+
size: () => Promise<number>;
22+
}
23+
24+
export type CompilationResult = EvalFunction;

0 commit comments

Comments
 (0)