Skip to content

Commit 99a626a

Browse files
nicktrnTrigger.dev RepoOps
authored andcommitted
fix(core): stop unflatten throwing on large numeric keys
Mono-RevId: 9f63c35a728fd1df7a15a85615eedf6ab0a993cf
1 parent e50689f commit 99a626a

3 files changed

Lines changed: 85 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Fix logs and spans with large numeric object keys failing to ingest and dropping the rest of their batch.

‎packages/core/src/v3/utils/flattenAttributes.ts‎

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const DEFAULT_MAX_DEPTH = 128;
99
// reconstruction and pollute the shared process.
1010
const PROTOTYPE_POLLUTION_KEY = "__proto__";
1111

12+
const MAX_ARRAY_HOLES = 1024;
13+
1214
export function flattenAttributes(
1315
obj: unknown,
1416
prefix?: string,
@@ -271,41 +273,52 @@ export function unflattenAttributes(
271273
}
272274

273275
const result: Record<string, unknown> = {};
276+
let everyTopLevelKeyIsArrayIndex = true;
277+
const entries: { segments: string[]; parsed: ParsedSegment[]; value: unknown }[] = [];
274278

275279
for (const [key, value] of Object.entries(obj)) {
276280
if (filteredKeys?.includes(key)) {
277281
continue;
278282
}
279283

280-
const parts = key.split(".").reduce(
281-
(acc, part) => {
282-
if (part.startsWith("[") && part.endsWith("]")) {
283-
// Handle array indices more precisely
284-
const match = part.match(/^\[(\d+)\]$/);
285-
if (match && match[1]) {
286-
acc.push(parseInt(match[1]));
287-
} else {
288-
// Remove brackets for non-numeric array keys
289-
acc.push(part.slice(1, -1));
290-
}
291-
} else {
292-
acc.push(part);
293-
}
294-
return acc;
295-
},
296-
[] as (string | number)[]
297-
);
284+
const segments = key.split(".");
285+
const parsed = segments.map(parseSegment);
298286

299287
// Skip keys that exceed max depth to prevent memory exhaustion
300-
if (parts.length > maxDepth) {
288+
if (parsed.length > maxDepth) {
301289
continue;
302290
}
303291

304292
// Skip any key whose path could walk into Object.prototype.
305-
if (parts.includes(PROTOTYPE_POLLUTION_KEY)) {
293+
if (parsed.some((segment) => segment.key === PROTOTYPE_POLLUTION_KEY)) {
306294
continue;
307295
}
308296

297+
entries.push({ segments, parsed, value });
298+
}
299+
300+
const maxArrayIndex = Object.keys(obj).length + MAX_ARRAY_HOLES;
301+
const oversizedArrayPaths = new Set<string>();
302+
for (const { segments, parsed } of entries) {
303+
parsed.forEach((segment, i) => {
304+
if (segment.index !== undefined && segment.index > maxArrayIndex) {
305+
oversizedArrayPaths.add(segments.slice(0, i).join("."));
306+
}
307+
});
308+
}
309+
310+
for (const { segments, parsed, value } of entries) {
311+
const parts = parsed.map((segment, i) =>
312+
segment.index !== undefined &&
313+
!(oversizedArrayPaths.size > 0 && oversizedArrayPaths.has(segments.slice(0, i).join(".")))
314+
? segment.index
315+
: segment.key
316+
);
317+
318+
if (typeof parts[0] !== "number") {
319+
everyTopLevelKeyIsArrayIndex = false;
320+
}
321+
309322
let current: any = result;
310323
for (let i = 0; i < parts.length - 1; i++) {
311324
const part = parts[i];
@@ -342,7 +355,11 @@ export function unflattenAttributes(
342355
// Convert the result to an array if all top-level keys are numeric indices.
343356
// Guard against an empty result (e.g. every key was skipped as unsafe), which
344357
// would otherwise produce Array(-Infinity) and throw.
345-
if (Object.keys(result).length > 0 && Object.keys(result).every((k) => /^\d+$/.test(k))) {
358+
if (
359+
everyTopLevelKeyIsArrayIndex &&
360+
Object.keys(result).length > 0 &&
361+
Object.keys(result).every((k) => /^\d+$/.test(k))
362+
) {
346363
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
347364
const arrayResult = Array(maxIndex + 1);
348365
for (const key in result) {
@@ -355,6 +372,19 @@ export function unflattenAttributes(
355372
return result;
356373
}
357374

375+
type ParsedSegment = { key: string; index?: number };
376+
377+
function parseSegment(segment: string): ParsedSegment {
378+
const match = segment.match(/^\[(\d+)\]$/);
379+
if (match && match[1]) {
380+
return { key: match[1], index: parseInt(match[1]) };
381+
}
382+
if (segment.startsWith("[") && segment.endsWith("]")) {
383+
return { key: segment.slice(1, -1) };
384+
}
385+
return { key: segment };
386+
}
387+
358388
function rehydrateCircular(value: any): any {
359389
if (value === CIRCULAR_REFERENCE_SENTINEL) {
360390
return "[Circular Reference]";

‎packages/core/test/flattenAttributes.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,4 +707,33 @@ describe("unflattenAttributes", () => {
707707
a: { b: ["indexed"] },
708708
});
709709
});
710+
711+
it("keeps top-level numeric object keys as an object instead of an array", () => {
712+
expect(unflattenAttributes({ "0": "a", "2": "b" })).toEqual({ "0": "a", "2": "b" });
713+
});
714+
715+
it("does not throw RangeError on a large numeric map key", () => {
716+
expect(() => unflattenAttributes({ "4294967296": "id" })).not.toThrow();
717+
expect(unflattenAttributes({ "4294967296": "id" })).toEqual({ "4294967296": "id" });
718+
});
719+
720+
it("keeps array indices far beyond the input size as object keys", () => {
721+
expect(unflattenAttributes({ "[4294967295]": "x" })).toEqual({ "4294967295": "x" });
722+
expect(unflattenAttributes({ "a.[4294967294]": "x" })).toEqual({ a: { "4294967294": "x" } });
723+
});
724+
725+
it("keeps every entry of an array that mixes nearby and oversized indices", () => {
726+
const expected = { a: { "0": "first", "2000": "far" } };
727+
expect(unflattenAttributes({ "a.[0]": "first", "a.[2000]": "far" })).toEqual(expected);
728+
expect(unflattenAttributes({ "a.[2000]": "far", "a.[0]": "first" })).toEqual(expected);
729+
});
730+
731+
it("ignores skipped keys when deciding whether an array is oversized", () => {
732+
expect(unflattenAttributes({ "a.[0]": "ok", "a.[2000].__proto__.x": "ignored" })).toEqual({
733+
a: ["ok"],
734+
});
735+
expect(unflattenAttributes({ "a.[0]": "first", "a.[2000]": "far" }, ["a.[2000]"])).toEqual({
736+
a: ["first"],
737+
});
738+
});
710739
});

0 commit comments

Comments
 (0)