fix(core): Charge element content for comparing array methods in native evaluation (no-changelog) (#41079)

This commit is contained in:
Mike Repeć 2026-10-14 15:40:38 +00:00 committed by GitHub
parent f5924004ca
commit fbe743b9df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 0 deletions

View File

@ -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<SimpleNode, { kind: 'call' }>, 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));
}

View File

@ -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

View File

@ -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<string>(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.