From fbe743b9df2cade9b8ad95a2fb4b2bb97cf8968f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Wed, 14 Oct 2026 15:40:38 +0000 Subject: [PATCH] fix(core): Charge element content for comparing array methods in native evaluation (no-changelog) (#41079) --- .../native-evaluation/evaluator.ts | 26 +++++++++++++++++++ .../expressions/native-evaluation/grammar.ts | 4 +++ .../test/native-evaluation-parity.test.ts | 22 ++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/workflow/src/expressions/native-evaluation/evaluator.ts b/packages/workflow/src/expressions/native-evaluation/evaluator.ts index 6883c56c657..80dde1648c3 100644 --- a/packages/workflow/src/expressions/native-evaluation/evaluator.ts +++ b/packages/workflow/src/expressions/native-evaluation/evaluator.ts @@ -301,6 +301,31 @@ function assertPrimitiveElements(receiver: unknown[]): void { } } +/** + * toSorted() stringifies both operands of every comparison, and includes(), + * indexOf() and lastIndexOf() compare each element with the search value, so + * their work follows element content, not element count. Returns that + * content, which the caller charges on top of the preflight bound. + */ +function elementContentWork(receiver: unknown[], method: string): number { + if (method === 'toSorted') { + let content = 0; + for (const element of receiver) content += toStr(element).length; + + // Each comparison reads both operands, so a pass can read the content twice. + return 2 * content * Math.ceil(Math.log2(receiver.length + 1)); + } + + if (method === 'includes' || method === 'indexOf' || method === 'lastIndexOf') { + let content = 0; + for (const element of receiver) content += typeof element === 'string' ? element.length : 1; + + return content; + } + + return 0; +} + /** * Content size of a JSON value: string lengths plus one per element and * key, stopping early past the budget. Deeper than the grammar's own depth @@ -391,6 +416,7 @@ function evalCall(node: Extract, env: Env): unknow if (node.method === 'concat') assertConcatWeight(receiver, args); } env.charge(preflightSize(receiver, node.method, args)); + if (isArray(receiver)) env.charge(elementContentWork(receiver, node.method)); return bounded(method.apply(receiver, args)); } diff --git a/packages/workflow/src/expressions/native-evaluation/grammar.ts b/packages/workflow/src/expressions/native-evaluation/grammar.ts index 6c245ecd3bb..9de6c0d5666 100644 --- a/packages/workflow/src/expressions/native-evaluation/grammar.ts +++ b/packages/workflow/src/expressions/native-evaluation/grammar.ts @@ -307,6 +307,10 @@ export const MAX_STEPS = 100_000; // visit does (a body can scan a MAX_RESULT_LENGTH string per element); this // bounds the product, in units the interpreter already computes, so it does // not depend on the clock or the machine. +// +// Rule for every allowlisted method: its charge is an upper bound on the +// characters it touches, not the elements it counts. A method that compares +// or stringifies elements is charged by their content. export const MAX_WORK = 10 * MAX_RESULT_LENGTH; // Nesting depth of the subset grammar. Parsing and evaluation both recurse diff --git a/packages/workflow/test/native-evaluation-parity.test.ts b/packages/workflow/test/native-evaluation-parity.test.ts index 40cb5da556a..cd3353bf9dd 100644 --- a/packages/workflow/test/native-evaluation-parity.test.ts +++ b/packages/workflow/test/native-evaluation-parity.test.ts @@ -483,6 +483,28 @@ describe('Expression - fast native evaluation parity', () => { }); }); + // Few elements, much content: an element count would charge almost + // nothing for work that compares or stringifies every character. + test('element-comparing methods are charged by element content', () => { + const big = 'x'.repeat(MAX_RESULT_LENGTH); + const fits = MAX_WORK / MAX_RESULT_LENGTH - 1; + const list = (count: number) => ({ $json: { list: new Array(count).fill(big) } }); + for (const method of ['includes', 'indexOf', 'lastIndexOf']) { + const expr = `{{ $json.list.${method}("y") }}`; + expect(nativeOn(expr, list(fits + 2))).toEqual({ handled: false }); + expect(nativeOn(expr, list(fits)).handled).toBe(true); + } + // Twice the content times log2 of the count: 3 elements cost 12 + // receivers, 2 cost 8. + expect(nativeOn('{{ $json.list.toSorted().length }}', list(3))).toEqual({ + handled: false, + }); + expect(nativeOn('{{ $json.list.toSorted().length }}', list(2))).toEqual({ + handled: true, + value: 2, + }); + }); + // concat can reference one payload string many times; the clone of the // result and every downstream method then pay for each reference, so it // is bounded by content size, not element count.