Skip to content

Commit ed87083

Browse files
committed
perf(webapp): compute delivery createdAt bounds from the two extreme ids
The delivery-id bounds calc decoded every id in the set to find the createdAt span. Delivery id bodies are base32hex(big-endian timestamp then random bytes), and base32hex is order-preserving, so lexical order equals chronological order: the earliest and latest timestamps sit at the lexical extremes. Track the min and max id in a single pass and decode only those two. A legacy cuid id starts with 'c', which sorts above every v1 body, so it always lands at the max extreme where decoding rejects it and the caller skips partition pruning. The list-hydration query shares the same single-pass min/max helper for its already-decoded page timestamps, dropping the last Math.min(...spread) in the delivery repository (the spread overflows the call stack on large inputs).
1 parent eff14ae commit ed87083

3 files changed

Lines changed: 81 additions & 30 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: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,57 @@
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.
12-
*
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).
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.
1510
*/
16-
export function deliveryIdsCreatedAtBounds(
17-
friendlyIds: string[]
18-
): { gte: Date; lte: Date } | undefined {
11+
export function createdAtMsBounds(msValues: number[]): { gte: Date; lte: Date } | undefined {
1912
let min = Number.POSITIVE_INFINITY;
2013
let max = Number.NEGATIVE_INFINITY;
2114

22-
for (const friendlyId of friendlyIds) {
23-
const timestamp = WebhookDeliveryId.parseTimestamp(friendlyId);
24-
if (!timestamp) return undefined;
25-
const ms = timestamp.getTime();
15+
for (const ms of msValues) {
2616
if (ms < min) min = ms;
2717
if (ms > max) max = ms;
2818
}
2919

3020
if (min === Number.POSITIVE_INFINITY) return undefined;
3121
return { gte: new Date(min), lte: new Date(max) };
3222
}
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.
27+
*
28+
* A v1 id body is `base32hex(big-endian ms timestamp ‖ random bytes)`, and base32hex is
29+
* order-preserving, so lexical id order equals chronological order. The earliest and latest
30+
* timestamps therefore sit at the lexical extremes, and we recover the span by decoding only those
31+
* two ids instead of all N. The embedded timestamp equals the row's `createdAt`, so `[gte, lte]`
32+
* covers every row in the set exactly.
33+
*
34+
* Returns `undefined` when the set is empty or either extreme fails to decode, so the caller adds no
35+
* `createdAt` predicate. A legacy (cuid) id starts with `c`, which sorts above every v1 body, so it
36+
* always lands at the max extreme where `parseTimestamp` rejects it: a non-time-encoded id can never
37+
* yield a bogus bound that would wrongly exclude its own row.
38+
*/
39+
export function deliveryIdsCreatedAtBounds(
40+
friendlyIds: string[]
41+
): { gte: Date; lte: Date } | undefined {
42+
if (friendlyIds.length === 0) return undefined;
43+
44+
let minBody = WebhookDeliveryId.toId(friendlyIds[0]!);
45+
let maxBody = minBody;
46+
for (let i = 1; i < friendlyIds.length; i++) {
47+
const body = WebhookDeliveryId.toId(friendlyIds[i]!);
48+
if (body < minBody) minBody = body;
49+
if (body > maxBody) maxBody = body;
50+
}
51+
52+
const gte = WebhookDeliveryId.parseTimestamp(minBody);
53+
const lte = WebhookDeliveryId.parseTimestamp(maxBody);
54+
if (!gte || !lte) return undefined;
55+
56+
return { gte, lte };
57+
}

apps/webapp/test/deliveryIdBounds.test.ts

Lines changed: 36 additions & 5 deletions
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));
@@ -39,14 +42,42 @@ describe("deliveryIdsCreatedAtBounds", () => {
3942
}
4043
});
4144

42-
it("returns undefined when any id is legacy (non-time-encoded), so the caller skips pruning", () => {
45+
it("returns undefined for a legacy (cuid) id even when it sits between two v1 ids", () => {
4346
vi.useFakeTimers();
4447
try {
45-
const v1 = idAt("2026-08-11T10:00:00.000Z");
46-
expect(deliveryIdsCreatedAtBounds([v1, "whd_legacycuidstyleid"])).toBeUndefined();
47-
expect(deliveryIdsCreatedAtBounds(["whd_legacycuidstyleid"])).toBeUndefined();
48+
const legacy = "whd_clwxyz00001234567890abcde";
49+
const early = idAt("2026-08-09T00:00:00.000Z");
50+
const late = idAt("2026-08-11T10:00:00.000Z");
51+
expect(deliveryIdsCreatedAtBounds([early, legacy, late])).toBeUndefined();
52+
expect(deliveryIdsCreatedAtBounds([late, legacy])).toBeUndefined();
53+
expect(deliveryIdsCreatedAtBounds([legacy])).toBeUndefined();
4854
} finally {
4955
vi.useRealTimers();
5056
}
5157
});
5258
});
59+
60+
describe("createdAtMsBounds", () => {
61+
it("returns undefined for an empty set", () => {
62+
expect(createdAtMsBounds([])).toBeUndefined();
63+
});
64+
65+
it("returns a zero-width span for a single value", () => {
66+
const bounds = createdAtMsBounds([1_000]);
67+
expect(bounds?.gte.getTime()).toBe(1_000);
68+
expect(bounds?.lte.getTime()).toBe(1_000);
69+
});
70+
71+
it("spans the smallest and largest value regardless of input order", () => {
72+
const bounds = createdAtMsBounds([50, 10, 30, 90, 40]);
73+
expect(bounds?.gte.getTime()).toBe(10);
74+
expect(bounds?.lte.getTime()).toBe(90);
75+
});
76+
77+
it("handles a large input without a stack overflow (unlike Math.min(...spread))", () => {
78+
const values = Array.from({ length: 300_000 }, (_, i) => i);
79+
const bounds = createdAtMsBounds(values);
80+
expect(bounds?.gte.getTime()).toBe(0);
81+
expect(bounds?.lte.getTime()).toBe(299_999);
82+
});
83+
});

0 commit comments

Comments
 (0)