assert: prevent OOM when generating diff for large objects#61347
assert: prevent OOM when generating diff for large objects#61347AbdelrahmanHafez wants to merge 7 commits into
Conversation
3b6e7b3 to
8905178
Compare
|
@avivkeller |
|
I've investigated the CI failures: GitHub Actions failures (coverage-linux, test-linux, test-tarball-linux, x86_64-linux): All terminated with exit code 143 due to runner shutdown signals, not test failures. The tests that completed before shutdown all passed. Jenkins CI failures (node-test-commit-linuxone, node-test-commit-plinux, node-test-linux-linked-*): These show "tests failed" but I cannot access the logs to verify if they're related to this PR or infrastructure issues. Could a maintainer please re-trigger CI or check the Jenkins logs? @avivkeller |
| // Maximum size for inspect output before truncation to prevent OOM. | ||
| // Objects with many converging paths can produce exponential growth in | ||
| // util.inspect output at high depths, leading to OOM during diff generation. | ||
| const kMaxInspectOutputLength = 2 * 1024 * 1024; // 2MB |
There was a problem hiding this comment.
Suggestion: split the string into chunks of 512kb and compare each chunk and combine the output. The output will not be perfect, as the combining process would sometimes be weird, while the smaller chunks should actually be much much faster to inspect in full.
There was a problem hiding this comment.
Good idea, will experiment with that and compare DX, and performance for any objects that are over 512kb. 👍
There was a problem hiding this comment.
@BridgeAR
I experimented with the chunking approach. Here's what I found:
For large objects (>1MB inspect output), chunking does provide a speedup (1.3-1.5x) when there are many scattered differences. Memory usage per chunk is lower, which helps prevent OOM.
But the problem is that chunks get misaligned. We split actual and expected independently by byte size, but if the values differ in length, the boundaries shift:
ACTUAL CHUNKS EXPECTED CHUNKS
Chunk 0:
prop_01: 'aaa', prop_01: 'aaa',
prop_02: 'bbb', (empty)
Chunk 1:
prop_03: 'ccc', prop_02: 'MUCH_LONGER_VALUE',
prop_04: 'ddd', (empty)
Chunk 2:
prop_05: 'eee', prop_03: 'ccc',
prop_06: 'fff', prop_04: 'ddd',
When we diff Chunk 1 (actual) vs Chunk 1 (expected), we're comparing [prop_03, prop_04] against [prop_02]. The diff shows fake inserts/deletes at every boundary.
For the typical use case, both approaches are equally fast (1ms each on my machine) because Myers diff is efficient when there are few actual changes.
I'm leaning towards the 2MB truncation approach. It skipped = true results in a less confusing output than phantom diffs when the object is too large, and the diff output is straightforward without these artifacts.
What do you think?
There was a problem hiding this comment.
Thank you for giving it a try!
There is absolutely a bigger likelihood for the chunks to misalign / create diffs that are not needed. That could be reduced by reading the last chunk start and using that for the actual splitting (adding the additional part). That would prevent most of these cases, while absolutely not all (especially, if the diff would be across multiple chunks).
All that said, I am fine with the 2mb truncation, since we do not want to show such a big diff anyway and it is simpler.
One problem that stays with 2mb: the available memory is depending on the available resources at that time on the machine.
There was a problem hiding this comment.
I'll experiment a little more some time next week if we can calculate diffs in a more robust way that handles the chunks misalignment, will keep you posted here. 👍
There was a problem hiding this comment.
The current test is actually so heavy, that it still fails on systems with low memory or a slow CPU. We should as such use a dynamic limit where we take into account system resources. I think on some systems a one MB limit should be max.
We could also skip it there, while we do not really gain much safety on those systems, if we do so.
|
Hi @BridgeAR I took a different approach to the chunking to work around the misaligned chunks giving phantom diffs. Instead of splitting at fixed positions, I find matching "anchor" lines near each chunk boundary, lines that appear in both arrays at roughly the same relative position. To avoid anchoring on lines that just happen to look similar (like Here's a quick overview of how it works:
I also addressed the other feedback:
I benchmarked the full
Apple Silicon, Node v24.12.0, 50 iterations with trimmed mean of middle 60%. Benchmark script (for reproducing)'use strict';
const assert = require('assert');
const WARMUP = 10;
const ITERATIONS = 50;
const scenarios = [
{
name: 'deepStrictEqual success, small object (10 fields)',
setup() {
const obj = createObject(10);
return [() => assert.deepStrictEqual(obj, { ...obj })];
},
},
{
name: 'deepStrictEqual success, large object (200 fields)',
setup() {
const obj = createObject(200);
return [() => assert.deepStrictEqual(obj, { ...obj })];
},
},
{
name: 'deepStrictEqual failure, small object (10 fields, 1 different)',
setup() {
const actual = createObject(10);
const expected = { ...actual, field_5: 'CHANGED' };
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'deepStrictEqual failure, medium object (50 fields, 3 different)',
setup() {
const actual = createObject(50);
const expected = {
...actual, field_10: 'X', field_25: 'Y', field_40: 'Z',
};
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'deepStrictEqual failure, large object (200 fields, 5 different)',
setup() {
const actual = createObject(200);
const expected = { ...actual };
for (let i = 0; i < 5; i++) {
expected['field_' + (i * 40)] = 'CHANGED_' + i;
}
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'deepStrictEqual failure, nested object (depth 4, breadth 3)',
setup() {
const actual = createNestedObject(4, 3);
const expected = createNestedObject(4, 3);
expected.child_1.child_0.child_2 = { value: 'DIFFERENT' };
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'deepStrictEqual failure, array (100 items, 2 different)',
setup() {
const actual = createArray(100);
const expected = createArray(100);
expected[30].name = 'CHANGED_30';
expected[70].name = 'CHANGED_70';
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'deepStrictEqual failure, array (500 items, 5 different)',
setup() {
const actual = createArray(500);
const expected = createArray(500);
for (let i = 0; i < 5; i++) expected[i * 100].name = 'CHANGED';
return [() => {
try { assert.deepStrictEqual(actual, expected); } catch {}
}];
},
},
{
name: 'strictEqual failure, long strings',
setup() {
const actual = 'a'.repeat(500) + 'ACTUAL' + 'b'.repeat(500);
const expected = 'a'.repeat(500) + 'EXPECTED' + 'b'.repeat(500);
return [() => {
try { assert.strictEqual(actual, expected); } catch {}
}];
},
},
];
// --- Main ---
const header =
'Scenario'.padEnd(58) +
'Avg (ms)'.padStart(10) +
'Median'.padStart(10);
console.log('Assert Benchmark');
console.log(
`Warmup: ${WARMUP}, Iterations: ${ITERATIONS}` +
' (trimmed mean of middle 60%)\n',
);
console.log(header);
console.log('-'.repeat(header.length));
for (const scenario of scenarios) {
const [fn] = scenario.setup();
const r = measure(fn);
console.log(
scenario.name.padEnd(58) +
r.avg.toFixed(3).padStart(10) +
r.median.toFixed(3).padStart(10),
);
}
// --- Helpers ---
function measure(fn) {
for (let i = 0; i < WARMUP; i++) fn();
const times = [];
for (let i = 0; i < ITERATIONS; i++) {
const start = process.hrtime.bigint();
fn();
const end = process.hrtime.bigint();
times.push(Number(end - start) / 1e6);
}
times.sort((a, b) => a - b);
const trimStart = Math.floor(times.length * 0.2);
const trimEnd = times.length - trimStart;
const trimmed = times.slice(trimStart, trimEnd);
const avg = trimmed.reduce((a, b) => a + b, 0) / trimmed.length;
const median = times[Math.floor(times.length / 2)];
return { avg, median };
}
function createObject(fieldCount) {
const obj = {};
for (let i = 0; i < fieldCount; i++) {
obj['field_' + i] = 'value_' + i;
}
return obj;
}
function createNestedObject(depth, breadth) {
if (depth === 0) return { value: 'leaf' };
const obj = {};
for (let i = 0; i < breadth; i++) {
obj['child_' + i] = createNestedObject(depth - 1, breadth);
}
return obj;
}
function createArray(length) {
return Array.from({ length }, (_, i) => ({
id: i, name: 'item_' + i, active: i % 2 === 0,
}));
} |
|
Kind reminder @BridgeAR @avivkeller |
Add a regression test for an issue where assert.strictEqual causes OOM when comparing objects with many converging paths to shared objects. The test creates an object graph similar to Mongoose documents and verifies that the assertion fails with an AssertionError rather than crashing. Signed-off-by: Hafez <a.hafez852@gmail.com>
Objects with many converging paths to shared objects can cause exponential growth in util.inspect output. When assert.strictEqual fails on such objects, the error message generation would OOM while trying to create a diff of the 100+ MB inspect strings. Add a 2MB limit to inspectValue() output. When truncation occurs, a marker is added and the error message indicates lines were skipped. The comparison itself is unaffected; only the error output is truncated. Signed-off-by: Hafez <a.hafez852@gmail.com>
Signed-off-by: Hafez <a.hafez852@gmail.com>
Signed-off-by: Hafez <a.hafez852@gmail.com>
Co-authored-by: Aviv Keller <me@aviv.sh> Signed-off-by: Hafez <a.hafez852@gmail.com>
Signed-off-by: Hafez <a.hafez852@gmail.com>
301f1b5 to
86c8df3
Compare
|
@BridgeAR @avivkeller I've updated the PR, chunk boundaries now step along the larger remaining side, so asymmetric comparisons (huge object vs tiny one) stay bounded too, and I rebased onto main. I ran the test workflows on my fork and everything passes apart from |
Chunk boundaries were derived from the actual side only, so a diff where one side was much larger than the other placed the entire oversized side in a single chunk. Myers diff memory is O((N + M) * D), which still allowed OOM while generating an assertion error message for asymmetric comparisons. Step boundaries along the larger remaining side so every chunk is bounded on both sides. Also truncate inspect output at a line boundary, report truncation via a flag instead of sniffing the marker text, and use a distinct message when both truncated outputs share an identical prefix instead of claiming the values have the same structure. Fix pre-existing lint errors flagged by make lint-js. Signed-off-by: Hafez <a.hafez852@gmail.com>
86c8df3 to
36af0f8
Compare
Fixes #61346
This PR:
inspectValue()output inassertion_error.jsbefore diffing. The limit scales with system memory viaos.totalmem(): 512KB under 1GB, 1MB under 2GB, 2MB otherwise. Truncation cuts at the last complete line, adds a... [truncated]marker, and the error message indicates lines were skipped.deepStrictEqual(hugeObject, { a: 1 })), which keeps the O((N + M) * D) memory of each chunk's diff bounded.The assertion logic is unaffected; only the error output changes.
Trade-offs
If both objects produce very large inspect output and differ only beyond the truncation limit, the diff can't show the difference. The error message says the output was truncated before any difference was found, and
error.actual/error.expectedare still available for programmatic inspection. The alternative is an OOM crash, which is worse.When no anchor line is found near a chunk boundary (e.g. completely different content), the split falls back to a proportional position. The diff stays correct, just not minimal around that boundary.
Benchmarks for the full assert paths are in #61347 (comment), the chunking overhead doesn't show up at the API level since
util.inspectdominates.