Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-queue-oldest-tenant-priority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/redis-worker": patch
---

Fix fair queue tenant selection so that, when a maximum tenant count is set, the tenants that have been waiting the longest are picked first instead of being ranked by their raw timestamp.
5 changes: 5 additions & 0 deletions .changeset/unflatten-attributes-large-numeric-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Fix a crash when unflattening attributes that hold an object whose keys are all large numbers, such as millisecond timestamps. Those values now come back as an object instead of throwing an "Invalid array length" error.
23 changes: 17 additions & 6 deletions packages/core/src/v3/utils/flattenAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";

const DEFAULT_MAX_DEPTH = 128;

// The largest value a JS array length can hold. A numeric key at or above this
// is not a real array index, so we never try to build an array that big.
const MAX_ARRAY_INDEX = 2 ** 32 - 1;

// This property name would let a crafted key walk into Object.prototype during
// reconstruction and pollute the shared process.
const PROTOTYPE_POLLUTION_KEY = "__proto__";
Expand Down Expand Up @@ -342,13 +346,20 @@ export function unflattenAttributes(
// Convert the result to an array if all top-level keys are numeric indices.
// Guard against an empty result (e.g. every key was skipped as unsafe), which
// would otherwise produce Array(-Infinity) and throw.
if (Object.keys(result).length > 0 && Object.keys(result).every((k) => /^\d+$/.test(k))) {
const maxIndex = Math.max(...Object.keys(result).map((k) => parseInt(k)));
const arrayResult = Array(maxIndex + 1);
for (const key in result) {
arrayResult[parseInt(key)] = result[key];
const topLevelKeys = Object.keys(result);
if (topLevelKeys.length > 0 && topLevelKeys.every((k) => /^\d+$/.test(k))) {
const maxIndex = topLevelKeys.reduce((max, k) => Math.max(max, parseInt(k)), 0);
// Only rebuild an array when every key is a real array index (< 2^32 - 1). A
// larger numeric key, like a millisecond timestamp used as an object key, is
// not an array index, so keep the object form instead of throwing "Invalid
// array length" or allocating a huge array.
if (maxIndex < MAX_ARRAY_INDEX) {
const arrayResult = Array(maxIndex + 1);
for (const key of topLevelKeys) {
arrayResult[parseInt(key)] = result[key];
}
return arrayResult as any;
}
Comment on lines +352 to 362

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Objects keyed by second-precision timestamps still expand into billion-slot lists

The cutoff for treating numeric keys as list positions is set at roughly 4.29 billion (maxIndex < MAX_ARRAY_INDEX at packages/core/src/v3/utils/flattenAttributes.ts:356), so data keyed by second-precision timestamps (about 1.7 billion) is still turned into a list with billions of empty slots, so displaying or saving that data can exhaust memory.
Impact: A task payload or output that uses second-based timestamps (or any other large-but-under-4-billion number) as its keys can make the server run out of memory when that data is written or rendered.

Why the 2^32 threshold does not cover the realistic key range

unflattenAttributes only avoids the array rebuild when the largest numeric key is >= 2^32-1. A unix timestamp in seconds (e.g. 1700000000) is below that, so Array(1700000001) is still allocated and every key is written into it. Downstream this value is serialized, e.g. JSON.stringify(unflattenAttributes(...)) in apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts:697, which would materialize ~1.7 billion null, entries and blow up memory. A density/size-based heuristic (e.g. only rebuild the array when maxIndex + 1 is within a small multiple of the number of keys, or below a modest constant like 100k) would cover both the millisecond and second timestamp cases.

Prompt for agents
In packages/core/src/v3/utils/flattenAttributes.ts, unflattenAttributes converts a result whose top-level keys are all numeric into an array. The new guard only skips the conversion when the largest key is >= 2^32-1, which prevents the RangeError for millisecond timestamps but still allows huge sparse arrays for smaller-but-large keys (e.g. second-precision unix timestamps around 1.7e9). Those arrays are later serialized (see apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts:697) and can exhaust memory. Consider a density-based check instead: only rebuild the array when the resulting length is reasonable relative to the number of keys (e.g. maxIndex + 1 <= keys.length * someSmallFactor, or below a modest absolute cap), otherwise keep the object form. Update the tests accordingly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return arrayResult as any;
}

return result;
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/flattenAttributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,4 +707,20 @@ describe("unflattenAttributes", () => {
a: { b: ["indexed"] },
});
});

it("keeps large numeric keys as an object instead of throwing Invalid array length", () => {
expect(() => unflattenAttributes({ "1699999999999": "value" })).not.toThrow();
expect(unflattenAttributes({ "1699999999999": "value" })).toEqual({
"1699999999999": "value",
});
});

it("keeps a nested object of large numeric keys as an object", () => {
expect(() =>
unflattenAttributes({ "a.1699999999999": "x", "a.1700000000000": "y" })
).not.toThrow();
expect(unflattenAttributes({ "a.1699999999999": "x", "a.1700000000000": "y" })).toEqual({
a: { "1699999999999": "x", "1700000000000": "y" },
});
});
});
10 changes: 6 additions & 4 deletions packages/redis-worker/src/fair-queue/schedulers/weighted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export class WeightedScheduler extends BaseScheduler {

// Apply maximum tenant count if configured
if (this.maximumTenantCount > 0) {
rawQueues = this.#selectTopTenantQueues(rawQueues);
rawQueues = this.#selectTopTenantQueues(rawQueues, now);
}

// Build tenant data
Expand Down Expand Up @@ -230,7 +230,7 @@ export class WeightedScheduler extends BaseScheduler {
return queues;
}

#selectTopTenantQueues(queues: QueueWithScore[]): QueueWithScore[] {
#selectTopTenantQueues(queues: QueueWithScore[], now: number): QueueWithScore[] {
// Group by tenant and calculate average age
const queuesByTenant = new Map<string, QueueWithScore[]>();
for (const queue of queues) {
Expand All @@ -239,9 +239,11 @@ export class WeightedScheduler extends BaseScheduler {
queuesByTenant.set(queue.tenantId, tenantQueues);
}

// Calculate average age per tenant
// Calculate average age per tenant. A queue's score is its oldest message
// timestamp, so age is now - score. Older queues have a higher age and
// should get more weight when we pick the top tenants.
const tenantAges = Array.from(queuesByTenant.entries()).map(([tenantId, tQueues]) => {
const avgAge = tQueues.reduce((sum, q) => sum + q.score, 0) / tQueues.length;
const avgAge = tQueues.reduce((sum, q) => sum + (now - q.score), 0) / tQueues.length;
Comment on lines +242 to +246

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Age-based weighting can drive freshly-enqueued tenants to weight 0

With avgAge = now - score, a tenant whose queues were just written has avgAge ≈ 0 and therefore weight ≈ 0. In the selection loop (packages/redis-worker/src/fair-queue/schedulers/weighted.ts:266-285) the inner while (random > 0 ...) never stops on a zero-weight entry, so such tenants are effectively only reachable via the index = Math.max(0, index - 1) fallback or once they are the only ones left. Previously (weighting by raw timestamp) all weights were near-identical, so selection was near-uniform. This is the intended direction of the fix, but it does mean the newest tenants can be nearly starved while maximumTenantCount is smaller than the tenant population — worth confirming that's acceptable, since #getQueuesFromShard already caps results at masterQueueLimit in score (oldest-first) order.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return { tenantId, avgAge };
});

Expand Down