diff --git a/package-lock.json b/package-lock.json index 7cb50d3c..58fd9eb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,10 @@ "underscore": "^1.13.7" } }, + "node_modules/@apify/actor-memory-expression": { + "resolved": "packages/actor-memory-expression", + "link": true + }, "node_modules/@apify/consts": { "resolved": "packages/consts", "link": true @@ -593,6 +597,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -6709,6 +6722,19 @@ "dot-prop": "^5.1.0" } }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -7968,6 +7994,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -8544,6 +8576,12 @@ "node": ">=6" } }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -9364,6 +9402,19 @@ "node": ">= 6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/front-matter": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", @@ -11389,6 +11440,12 @@ "node": ">=10" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -13654,6 +13711,29 @@ "node": ">= 0.4" } }, + "node_modules/mathjs": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.0.tgz", + "integrity": "sha512-HfnAcScQm9drGryodlDqeS3WAl4gUTYGDcOtcqL/8s23MZ28Ib1i8XnYK3ZdjNuaW/L4BAp9lIp8vxAMrcuu1w==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -17036,6 +17116,12 @@ "dev": true, "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -18096,6 +18182,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -18599,6 +18691,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", + "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -19324,6 +19425,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/actor-memory-expression": { + "name": "@apify/actor-memory-expression", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@apify/consts": "^2.47.0", + "@apify/log": "^2.5.26", + "mathjs": "^15.1.0" + } + }, "packages/consts": { "name": "@apify/consts", "version": "2.47.0", @@ -19437,6 +19548,17 @@ "node": ">= 18.0.0" } }, + "packages/math-utils": { + "name": "@apify/math-utils", + "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@apify/consts": "^2.47.0", + "@apify/log": "^2.5.26", + "mathjs": "^15.1.0" + } + }, "packages/payment_qr_codes": { "name": "@apify/payment_qr_codes", "version": "0.2.1", diff --git a/packages/actor-memory-expression/CHANGELOG.md b/packages/actor-memory-expression/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/actor-memory-expression/package.json b/packages/actor-memory-expression/package.json new file mode 100644 index 00000000..8e6dcc81 --- /dev/null +++ b/packages/actor-memory-expression/package.json @@ -0,0 +1,55 @@ +{ + "name": "@apify/actor-memory-expression", + "version": "0.0.1", + "description": "Utility to evaluate dynamic memory expressions for Apify actors.", + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "typings": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.cjs" + } + } + }, + "keywords": [ + "apify" + ], + "author": { + "name": "Apify", + "email": "support@apify.com", + "url": "https://apify.com" + }, + "contributors": [ + "Jan Curn ", + "Marek Trunkát " + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/apify/apify-shared-js" + }, + "bugs": { + "url": "https://github.com/apify/apify-shared-js/issues" + }, + "homepage": "https://apify.com", + "scripts": { + "build": "npm run clean && npm run compile && npm run copy", + "clean": "rimraf ./dist", + "compile": "tsup", + "copy": "ts-node -T ../../scripts/copy.ts" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@apify/consts": "^2.47.0", + "@apify/log": "^2.5.26", + "mathjs": "^15.1.0" + } +} diff --git a/packages/actor-memory-expression/src/index.ts b/packages/actor-memory-expression/src/index.ts new file mode 100644 index 00000000..84b3d306 --- /dev/null +++ b/packages/actor-memory-expression/src/index.ts @@ -0,0 +1 @@ +export * from './memory_calculator'; diff --git a/packages/actor-memory-expression/src/memory_calculator.ts b/packages/actor-memory-expression/src/memory_calculator.ts new file mode 100644 index 00000000..4729ca5a --- /dev/null +++ b/packages/actor-memory-expression/src/memory_calculator.ts @@ -0,0 +1,235 @@ +// MathJS bundle with only numbers is ~2x smaller than the default one. +import { + addDependencies, + andDependencies, + compileDependencies, + create, + divideDependencies, + type EvalFunction, + evaluateDependencies, + maxDependencies, + minDependencies, + multiplyDependencies, + notDependencies, + // @ts-expect-error nullishDependencies is not declared in types. https://github.com/josdejong/mathjs/issues/3597 + nullishDependencies, + orDependencies, + subtractDependencies, + xorDependencies, +} from 'mathjs'; + +import { ACTOR_LIMITS } from '@apify/consts'; + +import type { ActorRunOptions, CompilationCache, MemoryEvaluationContext } from './types.js'; + +// In theory, users could create expressions longer than 1000 characters, +// but in practice, it's unlikely anyone would need that much complexity. +// Later we can increase this limit if needed. +export const DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH = 1000; + +/** + * A Set of allowed keys from ActorRunOptions that can be used in + * the {{runOptions.variable}} syntax. + */ +const ALLOWED_RUN_OPTION_KEYS = new Set([ + 'build', + 'timeoutSecs', + 'memoryMbytes', + 'diskMbytes', + 'maxItems', + 'maxTotalChargeUsd', + 'restartOnError', +]); + +/** + * Create a mathjs instance with selected dependencies, then disable potentially dangerous ones. + * MathJS security recommendations: https://mathjs.org/docs/expressions/security.html + */ +const math = create({ + // expression dependencies + // Required for compiling and evaluating root expressions. + // We disable it below to prevent users from calling `evaluate()` inside their expressions. + // For example: defaultMemoryMbytes = "evaluate('2 + 2')" + compileDependencies, + evaluateDependencies, + + // arithmetic dependencies + addDependencies, + subtractDependencies, + multiplyDependencies, + divideDependencies, + // statistics dependencies + maxDependencies, + minDependencies, + // logical dependencies + andDependencies, + notDependencies, + orDependencies, + xorDependencies, + // without that dependency 'null ?? 5', won't work + nullishDependencies, +}); +const { compile } = math; + +// Disable potentially dangerous functions +math.import({ + // We disable evaluate to prevent users from calling it inside their expressions. + // For example: defaultMemoryMbytes = "evaluate('2 + 2')" + evaluate() { throw new Error('Function evaluate is disabled.'); }, + compile() { throw new Error('Function compile is disabled.'); }, + // We need to disable it, because compileDependencies imports parseDependencies. + parse() { throw new Error('Function parse is disabled.'); }, +}, { override: true }); + +/** + * Safely retrieves a nested property from an object using a dot-notation string path. + * + * This is custom function designed to be injected into the math expression evaluator, + * allowing expressions like `get(input, 'user.settings.memory', 512)` or `get(input, 'startUrls.length', 10)` to get array length. + * + * @param obj The source object to search within. + * @param path A dot-separated string representing the nested path (e.g., "input.payload.size"). + * @param defaultVal The value to return if the path is not found or the value is `null` or `undefined`. + * @returns The retrieved value, or `defaultVal` if the path is unreachable. +*/ +const customGetFunc = (obj: any, path: string, defaultVal?: number) => { + return (path.split('.').reduce((current, key) => current?.[key], obj)) ?? defaultVal; +}; + +/** + * Rounds a number to the closest power of 2. + * The result is clamped to the allowed range (ACTOR_LIMITS.MIN_RUN_MEMORY_MBYTES - ACTOR_LIMITS.MAX_RUN_MEMORY_MBYTES). + * @param num The number to round. + * @returns The closest power of 2 within min/max range. +*/ +const roundToClosestPowerOf2 = (num: number): number => { + if (typeof num !== 'number' || Number.isNaN(num) || !Number.isFinite(num)) { + throw new Error(`Calculated memory value is not a valid number: ${num}.`); + } + + // Handle 0 or negative values. + if (num <= 0) { + throw new Error(`Calculated memory value must be a positive number, greater than 0, got: ${num}.`); + } + + const log2n = Math.log2(num); + + const roundedLog = Math.round(log2n); + const result = 2 ** roundedLog; + + return Math.max(ACTOR_LIMITS.MIN_RUN_MEMORY_MBYTES, Math.min(result, ACTOR_LIMITS.MAX_RUN_MEMORY_MBYTES)); +}; + +/** + * Replaces all `{{variable}}` placeholders in an expression into direct + * property access (e.g. `{{runOptions.memoryMbytes}}` → `runOptions.memoryMbytes`). + * + * All `input.*` values are accepted, while `runOptions.*` are validated (7 variables from ALLOWED_RUN_OPTION_KEYS). + * + * Note: While not really needed for Math.js, this approach allows developers + * to use a consistent double-brace templating syntax `{{runOptions.timeoutSecs}}` + * across the Apify platform. We also want to avoid compiling the expression with the + * actual values as that would make caching less effective. + * + * @example + * // Returns "runOptions.memoryMbytes + 1024" + * preprocessDefaultMemoryExpression("{{runOptions.memoryMbytes}} + 1024"); + * + * @param defaultMemoryMbytes The raw string expression, e.g., "{{runOptions.memoryMbytes}} * 2". + * @returns A safe, processed expression for evaluation, e.g., "runOptions.memoryMbytes * 2". + */ +const processTemplateVariables = (defaultMemoryMbytes: string): string => { + const variableRegex = /{{\s*([a-zA-Z0-9_.]+)\s*}}/g; + + const processedExpression = defaultMemoryMbytes.replace( + variableRegex, + (_, variableName: string) => { + // 1. Check if the variable is accessing input (e.g. {{input.someValue}}) + // We do not validate the specific property name because `input` is dynamic. + if (variableName.startsWith('input.')) { + return variableName; + } + + // 2. Check if the variable is accessing runOptions (e.g. {{runOptions.memoryMbytes}}) and validate the keys. + if (variableName.startsWith('runOptions.')) { + const key = variableName.slice('runOptions.'.length); + if (!ALLOWED_RUN_OPTION_KEYS.has(key as keyof ActorRunOptions)) { + throw new Error( + `Invalid variable '{{${variableName}}}' in expression. Only the following runOptions are allowed: ${Array.from(ALLOWED_RUN_OPTION_KEYS).map((k) => `runOptions.${k}`).join(', ')}.`, + ); + } + return variableName; + } + + // 3. Throw error for unrecognized variables (e.g. {{someVariable}}) + throw new Error( + `Invalid variable '{{${variableName}}}' in expression.`, + ); + }, + ); + + return processedExpression; +}; + +/* +* Retrieves a compiled expression from the cache or compiles it if not present. +* +* @param expression The expression string to compile. +* @param cache An optional cache to store/retrieve compiled expressions. +* @returns The compiled EvalFunction. +*/ +const getCompiledExpression = async (expression: string, cache: CompilationCache | undefined): Promise => { + if (!cache) { + return compile(expression); + } + + let compiledExpression = await cache.get(expression); + + if (!compiledExpression) { + compiledExpression = compile(expression); + await cache.set(expression, compiledExpression!); + } + + return compiledExpression; +}; + +/** + * Evaluates a dynamic memory expression string using the provided context. + * Result is rounded to the closest power of 2 and clamped within allowed limits. + * + * @param defaultMemoryMbytes The string expression to evaluate (e.g., `get(input, 'urls.length', 10) * 1024` for `input = { urls: ['url1', 'url2'] }`). + * @param context The `MemoryEvaluationContext` (containing `input` and `runOptions`) available to the expression. + * @param options.cache Optional synchronous cache. Since compiled functions cannot be saved to a database/Redis, they are kept in local memory. + * @returns The calculated memory value rounded to the closest power of 2 and clamped within allowed limits. +*/ +export const calculateRunDynamicMemory = async ( + defaultMemoryMbytes: string, + context: MemoryEvaluationContext, + options: { cache: CompilationCache } | undefined = undefined, +) => { + if (defaultMemoryMbytes.length > DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH) { + throw new Error(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`); + } + + // Replaces all occurrences of {{variable}} with variable + // e.g., "{{runOptions.memoryMbytes}} + 1024" becomes "runOptions.memoryMbytes + 1024" + const preprocessedExpression = processTemplateVariables(defaultMemoryMbytes); + + const preparedContext = { + ...context, + get: customGetFunc, + }; + + const compiledExpression = await getCompiledExpression(preprocessedExpression, options?.cache); + + let finalResult: number | { entries: number[] } = compiledExpression.evaluate(preparedContext); + + // Mathjs wraps multi-line expressions in an object, so we need to extract the last entry. + // Note: one-line expressions return a number directly. + if (finalResult && typeof finalResult === 'object' && 'entries' in finalResult) { + const { entries } = finalResult; + finalResult = entries[entries.length - 1]; + } + + return roundToClosestPowerOf2(finalResult); +}; diff --git a/packages/actor-memory-expression/src/types.ts b/packages/actor-memory-expression/src/types.ts new file mode 100644 index 00000000..daf589f6 --- /dev/null +++ b/packages/actor-memory-expression/src/types.ts @@ -0,0 +1,22 @@ +import type { EvalFunction } from 'mathjs'; + +export type ActorRunOptions = { + build?: string; + timeoutSecs?: number; + memoryMbytes?: number; // probably no one will need it, but let's keep it consistent + diskMbytes?: number; // probably no one will need it, but let's keep it consistent + maxItems?: number; + maxTotalChargeUsd?: number; + restartOnError?: boolean; +} + +export type MemoryEvaluationContext = { + runOptions: ActorRunOptions; + input: Record; +} + +export type CompilationCache = { + get: (expression: string) => Promise; + set: (expression: string, compilationResult: EvalFunction) => Promise; + size: () => Promise; +} diff --git a/packages/actor-memory-expression/tsconfig.build.json b/packages/actor-memory-expression/tsconfig.build.json new file mode 100644 index 00000000..3f47ba58 --- /dev/null +++ b/packages/actor-memory-expression/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "emitDeclarationOnly": true + }, + "include": ["src/**/*"] +} diff --git a/packages/actor-memory-expression/tsconfig.json b/packages/actor-memory-expression/tsconfig.json new file mode 100644 index 00000000..52d43eaa --- /dev/null +++ b/packages/actor-memory-expression/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"] +} diff --git a/packages/actor-memory-expression/tsup.config.ts b/packages/actor-memory-expression/tsup.config.ts new file mode 100644 index 00000000..90c4b2c9 --- /dev/null +++ b/packages/actor-memory-expression/tsup.config.ts @@ -0,0 +1,4 @@ +import { createTsupConfig } from '../../scripts/tsup.config'; + +// eslint-disable-next-line import/no-default-export +export default createTsupConfig({}); diff --git a/test/memory_calculator.test.ts b/test/memory_calculator.test.ts new file mode 100644 index 00000000..b50e3575 --- /dev/null +++ b/test/memory_calculator.test.ts @@ -0,0 +1,248 @@ +import type { EvalFunction } from 'mathjs'; +import type { CompilationCache } from 'packages/actor-memory-expression/src/types'; + +import { calculateRunDynamicMemory, DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH } from '@apify/actor-memory-expression'; +import { LruCache } from '@apify/datastructures'; + +describe('calculateDefaultMemoryFromExpression', () => { + const emptyContext = { input: {}, runOptions: {} }; + + describe('Basic evaluation', () => { + it('correctly calculates and rounds memory from one-line expression', async () => { + const context = { input: { size: 10 }, runOptions: {} }; + // 10 * 1024 = 10240. log2(10240) ~ 13.32. round(13.32) -> 2^13 = 8192 + const result = await calculateRunDynamicMemory('input.size * 1024', context); + expect(result).toBe(8192); + }); + + it('correctly calculates and rounds memory from multi-line expression', async () => { + const context = { input: { base: 10, multiplier: 1024 }, runOptions: {} }; + const expr = ` + baseVal = input.base; + multVal = input.multiplier; + baseVal * multVal + `; + // 10 * 1024 = 10240. Rounds to 8192. + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(8192); + }); + + it('correctly accesses runOptions from the context', async () => { + const context = { input: {}, runOptions: { timeoutSecs: 60 } }; + const expr = 'runOptions.timeoutSecs * 100'; // 60 * 100 = 6000 + // log2(6000) ~ 12.55. round(13) -> 2^13 = 8192 + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(8192); + }); + + it('correctly handles a single number expression', async () => { + const result = await calculateRunDynamicMemory('2048', emptyContext); + expect(result).toBe(2048); + }); + + it('correctly handles expressions with custom get() function', async () => { + const context = { input: { nested: { value: 20 } }, runOptions: {} }; + const expr = "get(input, 'nested.value', 10) * 50"; // 20 * 50 = 1000 + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(1024); + }); + + it('should use get() default value when path is invalid', async () => { + const context = { input: { user: {} }, runOptions: {} }; + const expr = "get(input, 'user.settings.memory', 512)"; + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(512); + }); + + describe('operations supported', () => { + const context = { + input: { }, + runOptions: { timeoutSecs: 60, memoryMbytes: 512 }, + }; + + // Note: all results are rounded to the closest power of 2 and clamped within limits. + const cases = [ + { expression: '128 + 5', result: 128, name: '+' }, + { expression: '128 - 5', result: 128, name: '-' }, + { expression: '128 / 5', result: 128, name: '/' }, + { expression: '128 * 5', result: 512, name: '*' }, + { expression: 'max(128, 2, 3)', result: 128, name: 'max()' }, + { expression: 'min(128, 512, 1024)', result: 128, name: 'min()' }, + { expression: '(true and false) ? 0 : 128', result: 128, name: 'and' }, + { expression: '(true or false) ? 128 : 0', result: 128, name: 'or' }, + { expression: '(true xor false) ? 128 : 0', result: 128, name: 'xor' }, + { expression: 'not(false) ? 128 : 0', result: 128, name: 'not' }, + { expression: 'null ?? 256', result: 256, name: 'nullish coalescing' }, + { expression: 'a = 128', result: 128, name: '=' }, + ]; + + it.each(cases)( + `supports operation '$name'`, + async ({ expression, result }) => { + // in case operation is not supported, mathjs will throw + // we round the result to the closest power of 2 and clamp within limits. + expect(await calculateRunDynamicMemory(expression, context)).toBe(result); + }, + ); + }); + + describe('operations supported', () => { + const context = { + input: { }, + runOptions: { timeoutSecs: 60, memoryMbytes: 512 }, + }; + + // Note: all results are rounded to the closest power of 2 and clamped within limits. + const cases = [ + { expression: 'evaluate(\'5 + 1\')', name: 'evaluate', error: 'Function evaluate is disabled.' }, + { expression: 'compile(\'5 + 1\')', name: 'compile', error: 'Function compile is disabled.' }, + { expression: "parse('3^2 + 4^2')", name: 'parse', error: 'Function parse is disabled.' }, + { expression: 'simplify(\'5 + 1\')', name: 'simplify', error: 'Undefined function simplify' }, + { expression: 'derivative(\'5 + 1\')', name: 'derivative', error: 'Undefined function derivative' }, + { expression: 'resolve(\'5 + 1\')', name: 'resolve', error: 'Undefined function resolve' }, + + { expression: 'import({ myvalue: 42 })', name: 'import', error: 'Undefined function import' }, + { expression: 'createUnit(\'foo\')', name: 'createUnit', error: 'Undefined function createUnit' }, + { expression: 'reviver(\'{"mathjs":"Unit"}\')', name: 'reviver', error: 'Undefined function reviver' }, + ]; + + it.each(cases)( + `supports operation '$name'`, + async ({ expression, error }) => { + // in case operation is not supported, mathjs will throw + // we round the result to the closest power of 2 and clamp within limits. + await expect(calculateRunDynamicMemory(expression, context)).rejects.toThrow(error); + }, + ); + }); + }); + + describe('Template {{variables}} support', () => { + it('should throw error if variable doesn\'t start with runOptions. or input.', async () => { + const context = { input: {}, runOptions: { memoryMbytes: 16 } }; + const expr = '{{nonexistentVariable}} * 1024'; + await expect(calculateRunDynamicMemory(expr, context)) + .rejects.toThrow(`Invalid variable '{{nonexistentVariable}}' in expression.`); + }); + + it('correctly evaluates valid runOptions property', async () => { + const context = { input: {}, runOptions: { memoryMbytes: 16 } }; + const expr = '{{runOptions.memoryMbytes}} * 1024'; + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(16384); + }); + + it('correctly evaluates input property', async () => { + const context = { input: { value: 16 }, runOptions: { } }; + const expr = '{{input.value}} * 1024'; + const result = await calculateRunDynamicMemory(expr, context); + expect(result).toBe(16384); + }); + + it('should throw error if runOptions property is not supported', async () => { + const context = { input: { value: 16 }, runOptions: { } }; + const expr = '{{runOptions.customVariable}} * 1024'; + await expect(calculateRunDynamicMemory(expr, context)) + .rejects.toThrow(`Invalid variable '{{runOptions.customVariable}}' in expression. Only the following runOptions are allowed:`); + }); + }); + + describe('Rounding logic', () => { + it('should round down (e.g., 10240 -> 8192)', async () => { + // 2^13 = 8192, 2^14 = 16384. + const result = await calculateRunDynamicMemory('10240', emptyContext); + expect(result).toBe(8192); + }); + + it('should round up (e.g., 13000 -> 16384)', async () => { + // 13000 is closer to 16384 than 8192. + const result = await calculateRunDynamicMemory('13000', emptyContext); + expect(result).toBe(16384); + }); + + it('should clamp to the minimum memory limit if the result is too low', async () => { + const result = await calculateRunDynamicMemory('64', emptyContext); + expect(result).toBe(128); + }); + + it('should clamp to the maximum memory limit if the result is too high', async () => { + const result = await calculateRunDynamicMemory('100000', emptyContext); + expect(result).toBe(32768); + }); + }); + + describe('Invalid/error handling', () => { + it('should throw an error if expression length is greater than DEFAULT_MEMORY_MBYTES_MAX_CHARS', async () => { + const expr = '1'.repeat(DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH + 1); + await expect(calculateRunDynamicMemory(expr, emptyContext)) + .rejects.toThrow(`The defaultMemoryMbytes expression is too long. Max length is ${DEFAULT_MEMORY_MBYTES_EXPRESSION_MAX_LENGTH} characters.`); + }); + + it('should throw an error for invalid syntax', async () => { + const expr = '1 +* 2'; + await expect(calculateRunDynamicMemory(expr, emptyContext)) + .rejects.toThrow(); + }); + + it('should throw error if result is 0', async () => { + await expect(calculateRunDynamicMemory('10 - 10', emptyContext)).rejects.toThrow(`Calculated memory value must be a positive number, greater than 0, got: 0.`); + }); + + it('should throw error if result is negative', async () => { + await expect(calculateRunDynamicMemory('5 - 10', emptyContext)).rejects.toThrow(`Calculated memory value must be a positive number, greater than 0, got: -5.`); + }); + + it('should throw error if result is NaN', async () => { + await expect(calculateRunDynamicMemory('0 / 0', emptyContext)).rejects.toThrow('Calculated memory value is not a valid number: NaN.'); + }); + + it('should throw error if result is Infinity', async () => { + await expect(calculateRunDynamicMemory('Infinity', emptyContext)).rejects.toThrow('Calculated memory value is not a valid number: Infinity.'); + await expect(calculateRunDynamicMemory('-Infinity', emptyContext)).rejects.toThrow('Calculated memory value is not a valid number: -Infinity.'); + }); + + it('should throw error if result is a non-numeric (string)', async () => { + await expect(calculateRunDynamicMemory("'hello'", emptyContext)).rejects.toThrow('Calculated memory value is not a valid number: hello.'); + }); + + it('should throw error when disabled functionality of MathJS is used', async () => { + await expect(calculateRunDynamicMemory('evaluate(512)', emptyContext)).rejects.toThrow('Function evaluate is disabled.'); + }); + }); + + describe('Caching', () => { + let cache: CompilationCache; + const context = { input: { size: 10 }, runOptions: {} }; + const expr = 'input.size * 1024'; + + beforeEach(() => { + const lruCache = new LruCache({ maxLength: 10 }); + cache = { + get: async (expression: string) => lruCache.get(expression), + set: async (expression: string, compilationResult: EvalFunction) => { lruCache.add(expression, compilationResult); }, + size: async () => lruCache.length(), + }; + }); + + it('correctly works with cache passed in options', async () => { + expect(await cache.size()).toBe(0); + + // First call - cache miss + const result1 = await calculateRunDynamicMemory(expr, context, { cache }); + expect(result1).toBe(8192); + expect(await cache.size()).toBe(1); // Expression is now cached + + // Second call - cache hit + const result2 = await calculateRunDynamicMemory(expr, context, { cache }); + expect(result2).toBe(8192); + expect(await cache.size()).toBe(1); // Cache length is unchanged + }); + + it('should cache different expressions separately', async () => { + const expr2 = 'input.size * 2048'; // 10 * 2048 = 20480 -> 16384 + await calculateRunDynamicMemory(expr, context, { cache }); + await calculateRunDynamicMemory(expr2, context, { cache }); + expect(await cache.size()).toBe(2); + }); + }); +});