Skip to content

Commit 840bf41

Browse files
committed
refactor(webapp): share the single-pass createdAt bounds calc across delivery queries
Route the list-hydration query through the same createdAtMsBounds helper the id-bounds path uses, dropping the last Math.min(...spread)/Math.max(...spread) in the delivery repository. The spread builds an O(n) argument list and overflows the call stack on large inputs; the helper is a single pass.
1 parent eff14ae commit 840bf41

3 files changed

Lines changed: 62 additions & 25 deletions

File tree

apps/webapp/app/services/webhookDeliveriesRepository/clickhouseWebhookDeliveriesRepository.server.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ClickhouseQueryBuilder } from "@internal/clickhouse";
22
import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic";
33
import { boundedIn } from "@trigger.dev/database";
4-
import { deliveryIdsCreatedAtBounds } from "./deliveryIdBounds";
4+
import { createdAtMsBounds, deliveryIdsCreatedAtBounds } from "./deliveryIdBounds";
55
import { decodeRunsCursor, encodeRunsCursor } from "../runsRepository/runsCursor.server";
66
import {
77
type CountDeliveriesByEndpointOptions,
@@ -195,18 +195,13 @@ export class ClickHouseWebhookDeliveriesRepository implements IWebhookDeliveries
195195
// `id IN (...)` query without a createdAt predicate scans every child
196196
// partition, so derive a [min, max] range from the CH page and pass it
197197
// through. This is the one place webhook hydration diverges from runs.
198-
const createdAtMsValues = pageRows.map((row) => row.createdAt);
199-
const minCreatedAtMs = Math.min(...createdAtMsValues);
200-
const maxCreatedAtMs = Math.max(...createdAtMsValues);
198+
const bounds = createdAtMsBounds(pageRows.map((row) => row.createdAt));
201199

202200
// CH gives the ordered id list; Postgres hydrates the full lean rows by PK id.
203201
const deliveries = await this.options.prisma.webhookDelivery.findMany({
204202
where: {
205203
id: { in: boundedIn(deliveryIds) },
206-
createdAt: {
207-
gte: new Date(minCreatedAtMs),
208-
lte: new Date(maxCreatedAtMs),
209-
},
204+
...(bounds ? { createdAt: bounds } : {}),
210205
},
211206
select: DELIVERY_LIST_SELECT,
212207
});
Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,46 @@
11
import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic";
22

33
/**
4-
* Compute the `createdAt` span covering a set of webhook delivery friendlyIds, for partition-pruning a
5-
* lookup by id on the RANGE-partitioned `WebhookDelivery` table.
4+
* Single-pass min/max over a set of `createdAt` timestamps (unix ms), returned as a Prisma
5+
* `{ gte, lte }` range for partition-pruning the RANGE-partitioned `WebhookDelivery` table.
66
*
7-
* Each v1 id is time-encoded (see `WebhookDeliveryId`) with the same timestamp the engine stores as the
8-
* row's `createdAt`, so the returned `[gte, lte]` covers every row in the set exactly. Returns
9-
* `undefined` when the set is empty or contains any legacy (non-time-encoded) id: in that case the
10-
* caller must not add a `createdAt` predicate, since a bound derived from only the decodable ids would
11-
* wrongly exclude the legacy rows.
7+
* Avoids `Math.min(...spread)` / `Math.max(...spread)`: the spread builds an O(n) argument list and
8+
* throws "Maximum call stack size exceeded" once the array is large (~1e5+ elements). Returns
9+
* `undefined` for an empty set, so the caller adds no `createdAt` predicate.
10+
*/
11+
export function createdAtMsBounds(msValues: number[]): { gte: Date; lte: Date } | undefined {
12+
let min = Number.POSITIVE_INFINITY;
13+
let max = Number.NEGATIVE_INFINITY;
14+
15+
for (const ms of msValues) {
16+
if (ms < min) min = ms;
17+
if (ms > max) max = ms;
18+
}
19+
20+
if (min === Number.POSITIVE_INFINITY) return undefined;
21+
return { gte: new Date(min), lte: new Date(max) };
22+
}
23+
24+
/**
25+
* Compute the `createdAt` span covering a set of webhook delivery friendlyIds, for partition-pruning
26+
* a lookup by id on the RANGE-partitioned `WebhookDelivery` table.
1227
*
13-
* Single pass, no intermediate arrays and no `Math.min(...spread)` (which is O(n) to build the argument
14-
* list and can overflow the call stack for large inputs).
28+
* Each v1 id is time-encoded (see `WebhookDeliveryId`) with the same timestamp the engine stores as
29+
* the row's `createdAt`, so the returned `[gte, lte]` covers every row in the set exactly. Returns
30+
* `undefined` when the set is empty or contains any legacy (non-time-encoded) id: in that case the
31+
* caller must not add a `createdAt` predicate, since a bound derived from only the decodable ids
32+
* would wrongly exclude the legacy rows.
1533
*/
1634
export function deliveryIdsCreatedAtBounds(
1735
friendlyIds: string[]
1836
): { gte: Date; lte: Date } | undefined {
19-
let min = Number.POSITIVE_INFINITY;
20-
let max = Number.NEGATIVE_INFINITY;
37+
const msValues: number[] = [];
2138

2239
for (const friendlyId of friendlyIds) {
2340
const timestamp = WebhookDeliveryId.parseTimestamp(friendlyId);
2441
if (!timestamp) return undefined;
25-
const ms = timestamp.getTime();
26-
if (ms < min) min = ms;
27-
if (ms > max) max = ms;
42+
msValues.push(timestamp.getTime());
2843
}
2944

30-
if (min === Number.POSITIVE_INFINITY) return undefined;
31-
return { gte: new Date(min), lte: new Date(max) };
45+
return createdAtMsBounds(msValues);
3246
}

apps/webapp/test/deliveryIdBounds.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { WebhookDeliveryId } from "@trigger.dev/core/v3/isomorphic";
3-
import { deliveryIdsCreatedAtBounds } from "../app/services/webhookDeliveriesRepository/deliveryIdBounds";
3+
import {
4+
createdAtMsBounds,
5+
deliveryIdsCreatedAtBounds,
6+
} from "../app/services/webhookDeliveriesRepository/deliveryIdBounds";
47

58
function idAt(iso: string): string {
69
vi.setSystemTime(new Date(iso));
@@ -50,3 +53,28 @@ describe("deliveryIdsCreatedAtBounds", () => {
5053
}
5154
});
5255
});
56+
57+
describe("createdAtMsBounds", () => {
58+
it("returns undefined for an empty set", () => {
59+
expect(createdAtMsBounds([])).toBeUndefined();
60+
});
61+
62+
it("returns a zero-width span for a single value", () => {
63+
const bounds = createdAtMsBounds([1_000]);
64+
expect(bounds?.gte.getTime()).toBe(1_000);
65+
expect(bounds?.lte.getTime()).toBe(1_000);
66+
});
67+
68+
it("spans the smallest and largest value regardless of input order", () => {
69+
const bounds = createdAtMsBounds([50, 10, 30, 90, 40]);
70+
expect(bounds?.gte.getTime()).toBe(10);
71+
expect(bounds?.lte.getTime()).toBe(90);
72+
});
73+
74+
it("handles a large input without a stack overflow (unlike Math.min(...spread))", () => {
75+
const values = Array.from({ length: 300_000 }, (_, i) => i);
76+
const bounds = createdAtMsBounds(values);
77+
expect(bounds?.gte.getTime()).toBe(0);
78+
expect(bounds?.lte.getTime()).toBe(299_999);
79+
});
80+
});

0 commit comments

Comments
 (0)