Skip to content

Commit 0bbee47

Browse files
committed
feat(sdk,core,webapp): offload large batch payloads to object storage
batchTrigger and batchTriggerAndWait (and the by-id and by-task variants) now offload any per-item payload over 128KB to object storage before sending, the same way single trigger and triggerAndWait already do, so a big batch no longer blows past the API body limit. Every trigger and batch item also reports its pre-offload serialised payload size, which the trigger span records so an offloaded payload shows its real size instead of the size of the object-store reference.
1 parent 5158ee8 commit 0bbee47

6 files changed

Lines changed: 233 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Large batch payloads now offload to object storage instead of riding inline in the trigger request. `batchTrigger` and `batchTriggerAndWait` (and the by-id and by-task variants) offload any per-item payload over 128KB before sending, the same way single `trigger` and `triggerAndWait` already do, so a big batch no longer blows past the API body limit.

apps/webapp/app/runEngine/concerns/payloads.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ export class DefaultPayloadProcessor implements PayloadProcessor {
2424
);
2525

2626
span.setAttribute("needsOffloading", needsOffloading);
27-
span.setAttribute("size", size);
27+
// When the caller already offloaded the payload (payloadType "application/store"), the
28+
// packet here is just the small object-store reference, so `size` measures the reference,
29+
// not the payload. Prefer the caller-reported pre-offload size when it's provided so the
30+
// span reflects the real payload size. For inline payloads the two agree.
31+
span.setAttribute("size", request.body.options?.payloadSize ?? size);
2832

2933
if (!needsOffloading) {
3034
return packet;

packages/core/src/v3/schemas/api-type.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { InitializeDeploymentRequestBody, TriggerTaskRequestBody } from "./api.js";
2+
import { BatchItemNDJSON, InitializeDeploymentRequestBody, TriggerTaskRequestBody } from "./api.js";
33
import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js";
44

55
describe("InitializeDeploymentRequestBody", () => {
@@ -176,4 +176,47 @@ describe("TriggerTaskRequestBody", () => {
176176

177177
expect(result.success).toBe(false);
178178
});
179+
180+
it("accepts an optional payloadSize on the options", () => {
181+
const result = TriggerTaskRequestBody.safeParse({
182+
payload: "packets/payloads/file.json",
183+
context: {},
184+
options: {
185+
payloadType: "application/store",
186+
payloadSize: 512 * 1024,
187+
},
188+
});
189+
190+
expect(result.success).toBe(true);
191+
expect(result.success && result.data.options?.payloadSize).toBe(512 * 1024);
192+
});
193+
194+
it("rejects a negative payloadSize", () => {
195+
const result = TriggerTaskRequestBody.safeParse({
196+
payload: { foo: "bar" },
197+
context: {},
198+
options: {
199+
payloadSize: -1,
200+
},
201+
});
202+
203+
expect(result.success).toBe(false);
204+
});
205+
});
206+
207+
describe("BatchItemNDJSON", () => {
208+
it("accepts an optional payloadSize on a batch item's options", () => {
209+
const result = BatchItemNDJSON.safeParse({
210+
index: 0,
211+
task: "my-task",
212+
payload: "packets/payloads/file.json",
213+
options: {
214+
payloadType: "application/store",
215+
payloadSize: 256 * 1024,
216+
},
217+
});
218+
219+
expect(result.success).toBe(true);
220+
expect(result.success && result.data.options?.payloadSize).toBe(256 * 1024);
221+
});
179222
});

packages/core/src/v3/schemas/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,12 @@ export const TriggerTaskRequestBody = z
237237
metadata: z.any(),
238238
metadataType: z.string().optional(),
239239
payloadType: z.string().optional(),
240+
/**
241+
* Byte size of the (pre-offload) serialized payload, measured by the caller
242+
* before any object-store offload. Lets the pipeline know how large a payload
243+
* is without downloading an "application/store" reference.
244+
*/
245+
payloadSize: z.number().int().nonnegative().optional(),
240246
tags: RunTags.optional(),
241247
test: z.boolean().optional(),
242248
ttl: z.string().or(z.number().nonnegative().int()).optional(),
@@ -310,6 +316,8 @@ export const BatchTriggerTaskItem = z.object({
310316
metadataType: z.string().optional(),
311317
parentAttempt: z.string().optional(),
312318
payloadType: z.string().optional(),
319+
/** Byte size of the (pre-offload) serialized payload for this item. */
320+
payloadSize: z.number().int().nonnegative().optional(),
313321
queue: z
314322
.object({
315323
name: z.string(),

packages/trigger-sdk/src/v3/shared.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,57 @@
1+
import { ApiClient } from "@trigger.dev/core/v3";
12
import { describe, it, expect } from "vitest";
2-
import { readableStreamToAsyncIterable } from "./shared.js";
3+
import { offloadBatchItemPayloads, readableStreamToAsyncIterable } from "./shared.js";
4+
5+
describe("offloadBatchItemPayloads", () => {
6+
// A real client is required for conditionallyExportPacket's truthy check; small payloads
7+
// short-circuit before any network call, so this never actually reaches the server.
8+
const apiClient = new ApiClient("http://localhost:3030", "tr_dev_test");
9+
10+
it("returns an empty array unchanged", async () => {
11+
expect(await offloadBatchItemPayloads([], apiClient)).toEqual([]);
12+
});
13+
14+
it("passes small payloads through and records their pre-offload byte size", async () => {
15+
const payload = JSON.stringify({ hello: "world" });
16+
const result = await offloadBatchItemPayloads(
17+
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
18+
apiClient
19+
);
20+
const item = result[0]!;
21+
22+
expect(item.payload).toBe(payload);
23+
expect(item.options?.payloadType).toBe("application/json");
24+
expect(item.options?.payloadSize).toBe(Buffer.byteLength(payload, "utf8"));
25+
});
26+
27+
it("measures multi-byte payloads by byte length, not character count", async () => {
28+
const payload = "€€€"; // 3 chars, 9 bytes in UTF-8
29+
const result = await offloadBatchItemPayloads(
30+
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
31+
apiClient
32+
);
33+
34+
expect(result[0]!.options?.payloadSize).toBe(9);
35+
});
36+
37+
it("leaves an already-offloaded (application/store) item untouched", async () => {
38+
const item = {
39+
index: 0,
40+
task: "my-task",
41+
payload: "trigger/my-task/123/payload.json",
42+
options: { payloadType: "application/store" },
43+
};
44+
45+
const result = await offloadBatchItemPayloads([item], apiClient);
46+
expect(result[0]).toEqual(item);
47+
});
48+
49+
it("leaves items without a string payload untouched", async () => {
50+
const item = { index: 0, task: "my-task", options: {} };
51+
const result = await offloadBatchItemPayloads([item], apiClient);
52+
expect(result[0]).toEqual(item);
53+
});
54+
});
355

456
describe("readableStreamToAsyncIterable", () => {
557
it("yields all values from the stream", async () => {

packages/trigger-sdk/src/v3/shared.ts

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getSchemaParseFn,
2424
lifecycleHooks,
2525
makeIdempotencyKey,
26+
packetRequiresOffloading,
2627
parsePacket,
2728
RateLimitError,
2829
resourceCatalog,
@@ -1655,6 +1656,21 @@ async function executeBatchTwoPhase(
16551656
},
16561657
requestOptions?: TriggerApiRequestOptions
16571658
): Promise<{ id: string; runCount: number; publicAccessToken: string; taskIdentifiers: string[] }> {
1659+
// Offload any oversized per-item payloads to object storage before streaming, so a batch
1660+
// of large items keeps the request body small — the same treatment single triggers get.
1661+
// Both the array and streaming batch paths funnel through here, so this is the one place
1662+
// batch offloading needs to happen. Wrap failures so a presign/upload error carries the
1663+
// same batch context (phase, itemCount, rate-limit info) as the create/stream phases.
1664+
try {
1665+
items = await offloadBatchItemPayloads(items, apiClient);
1666+
} catch (error) {
1667+
throw new BatchTriggerError(`Failed to offload payloads for batch with ${items.length} items`, {
1668+
cause: error,
1669+
phase: "offload",
1670+
itemCount: items.length,
1671+
});
1672+
}
1673+
16581674
let batch: Awaited<ReturnType<typeof apiClient.createBatch>> | undefined;
16591675

16601676
try {
@@ -1701,6 +1717,81 @@ async function executeBatchTwoPhase(
17011717
};
17021718
}
17031719

1720+
/**
1721+
* Offload any oversized per-item payloads in a batch to object storage, mirroring the
1722+
* single-trigger offload path. Small payloads pass through untouched. Every returned item
1723+
* carries `options.payloadSize` (the pre-offload serialized byte size) so the pipeline can
1724+
* reason about payload size without downloading an "application/store" reference.
1725+
*
1726+
* Uploads run with bounded concurrency so a batch of large items doesn't fire an unbounded
1727+
* number of simultaneous presigned PUTs.
1728+
*
1729+
* @internal Exported for testing.
1730+
*/
1731+
export async function offloadBatchItemPayloads(
1732+
items: BatchItemNDJSON[],
1733+
apiClient: ApiClient,
1734+
concurrency = 10
1735+
): Promise<BatchItemNDJSON[]> {
1736+
if (items.length === 0) {
1737+
return items;
1738+
}
1739+
1740+
const results = new Array<BatchItemNDJSON>(items.length);
1741+
let cursor = 0;
1742+
1743+
async function worker() {
1744+
while (cursor < items.length) {
1745+
const index = cursor++;
1746+
results[index] = await offloadBatchItemPayload(items[index]!, apiClient);
1747+
}
1748+
}
1749+
1750+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
1751+
1752+
return results;
1753+
}
1754+
1755+
async function offloadBatchItemPayload(
1756+
item: BatchItemNDJSON,
1757+
apiClient: ApiClient
1758+
): Promise<BatchItemNDJSON> {
1759+
// The batch builders serialize each payload to a string via stringifyIO before we get
1760+
// here; anything else has no inline body to offload or measure.
1761+
if (typeof item.payload !== "string" || item.payload.length === 0) {
1762+
return item;
1763+
}
1764+
1765+
const dataType = item.options?.payloadType ?? "application/json";
1766+
1767+
// Already an object-store reference — the caller pre-offloaded it, nothing to do.
1768+
if (dataType === "application/store") {
1769+
return item;
1770+
}
1771+
1772+
const packet: IOPacket = { data: item.payload, dataType };
1773+
// Measure before offload so the size reflects the real payload, not the small reference
1774+
// we may replace it with.
1775+
const { size: payloadSize } = packetRequiresOffloading(packet);
1776+
1777+
const exported = await conditionallyExportPacket(
1778+
packet,
1779+
createTriggerPayloadPathPrefix(item.task),
1780+
undefined,
1781+
apiClient
1782+
);
1783+
1784+
return {
1785+
...item,
1786+
payload: exported.data,
1787+
options: {
1788+
...item.options,
1789+
payloadType: exported.dataType,
1790+
payloadSize,
1791+
},
1792+
};
1793+
}
1794+
17041795
/**
17051796
* Error thrown when batch trigger operations fail.
17061797
* Includes context about which phase failed and the batch details.
@@ -1710,7 +1801,7 @@ async function executeBatchTwoPhase(
17101801
* - `retryAfterMs`: milliseconds until the rate limit resets
17111802
*/
17121803
export class BatchTriggerError extends Error {
1713-
readonly phase: "create" | "stream";
1804+
readonly phase: "offload" | "create" | "stream";
17141805
readonly batchId?: string;
17151806
readonly itemCount: number;
17161807

@@ -1730,7 +1821,7 @@ export class BatchTriggerError extends Error {
17301821
message: string,
17311822
options: {
17321823
cause?: unknown;
1733-
phase: "create" | "stream";
1824+
phase: "offload" | "create" | "stream";
17341825
batchId?: string;
17351826
itemCount: number;
17361827
}
@@ -2205,7 +2296,11 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
22052296
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
22062297

22072298
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2208-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2299+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2300+
parsedPayload,
2301+
apiClient,
2302+
id
2303+
);
22092304

22102305
// Process idempotency key and extract options for storage
22112306
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
@@ -2222,6 +2317,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
22222317
concurrencyKey: options?.concurrencyKey,
22232318
test: taskContext.ctx?.run.isTest,
22242319
payloadType: triggerPayloadPacket.dataType,
2320+
payloadSize,
22252321
idempotencyKey: processedIdempotencyKey?.toString(),
22262322
idempotencyKeyTTL: options?.idempotencyKeyTTL,
22272323
idempotencyKeyOptions,
@@ -2460,7 +2556,11 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
24602556
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
24612557

24622558
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2463-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2559+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2560+
parsedPayload,
2561+
apiClient,
2562+
id
2563+
);
24642564

24652565
// Process idempotency key and extract options for storage
24662566
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
@@ -2481,6 +2581,7 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
24812581
concurrencyKey: options?.concurrencyKey,
24822582
test: taskContext.ctx?.run.isTest,
24832583
payloadType: triggerPayloadPacket.dataType,
2584+
payloadSize,
24842585
delay: options?.delay,
24852586
ttl: options?.ttl,
24862587
tags: options?.tags,
@@ -2546,7 +2647,11 @@ async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload
25462647
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
25472648

25482649
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2549-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2650+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2651+
parsedPayload,
2652+
apiClient,
2653+
id
2654+
);
25502655

25512656
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
25522657
const idempotencyKeyOptions = processedIdempotencyKey
@@ -2566,6 +2671,7 @@ async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload
25662671
concurrencyKey: options?.concurrencyKey,
25672672
test: taskContext.ctx?.run.isTest,
25682673
payloadType: triggerPayloadPacket.dataType,
2674+
payloadSize,
25692675
delay: options?.delay,
25702676
ttl: options?.ttl,
25712677
tags: options?.tags,
@@ -3070,14 +3176,18 @@ async function prepareTriggerPayload(
30703176
payload: unknown,
30713177
apiClient: ApiClient,
30723178
taskId: string
3073-
): Promise<IOPacket> {
3179+
): Promise<{ packet: IOPacket; payloadSize: number }> {
30743180
const payloadPacket = await stringifyIO(payload);
3075-
return await conditionallyExportPacket(
3181+
// Measure the serialized size before any offload, so it reflects the real payload
3182+
// size rather than the small "application/store" reference we may send instead.
3183+
const { size: payloadSize } = packetRequiresOffloading(payloadPacket);
3184+
const packet = await conditionallyExportPacket(
30763185
payloadPacket,
30773186
createTriggerPayloadPathPrefix(taskId),
30783187
undefined,
30793188
apiClient
30803189
);
3190+
return { packet, payloadSize };
30813191
}
30823192

30833193
function createTriggerPayloadPathPrefix(taskId: string): string {

0 commit comments

Comments
 (0)