Skip to content

Commit 3ff70ab

Browse files
committed
feat(sdk,core): 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 on the request options, so a consumer can tell how big a payload is without fetching the offloaded application/store reference.
1 parent f101983 commit 3ff70ab

5 files changed

Lines changed: 217 additions & 7 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.

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: 106 additions & 5 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,12 @@ 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.
1663+
items = await offloadBatchItemPayloads(items, apiClient);
1664+
16581665
let batch: Awaited<ReturnType<typeof apiClient.createBatch>> | undefined;
16591666

16601667
try {
@@ -1701,6 +1708,81 @@ async function executeBatchTwoPhase(
17011708
};
17021709
}
17031710

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

22072289
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2208-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2290+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2291+
parsedPayload,
2292+
apiClient,
2293+
id
2294+
);
22092295

22102296
// Process idempotency key and extract options for storage
22112297
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
@@ -2222,6 +2308,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
22222308
concurrencyKey: options?.concurrencyKey,
22232309
test: taskContext.ctx?.run.isTest,
22242310
payloadType: triggerPayloadPacket.dataType,
2311+
payloadSize,
22252312
idempotencyKey: processedIdempotencyKey?.toString(),
22262313
idempotencyKeyTTL: options?.idempotencyKeyTTL,
22272314
idempotencyKeyOptions,
@@ -2460,7 +2547,11 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
24602547
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
24612548

24622549
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2463-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2550+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2551+
parsedPayload,
2552+
apiClient,
2553+
id
2554+
);
24642555

24652556
// Process idempotency key and extract options for storage
24662557
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
@@ -2481,6 +2572,7 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
24812572
concurrencyKey: options?.concurrencyKey,
24822573
test: taskContext.ctx?.run.isTest,
24832574
payloadType: triggerPayloadPacket.dataType,
2575+
payloadSize,
24842576
delay: options?.delay,
24852577
ttl: options?.ttl,
24862578
tags: options?.tags,
@@ -2546,7 +2638,11 @@ async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload
25462638
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
25472639

25482640
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
2549-
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
2641+
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
2642+
parsedPayload,
2643+
apiClient,
2644+
id
2645+
);
25502646

25512647
const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
25522648
const idempotencyKeyOptions = processedIdempotencyKey
@@ -2566,6 +2662,7 @@ async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload
25662662
concurrencyKey: options?.concurrencyKey,
25672663
test: taskContext.ctx?.run.isTest,
25682664
payloadType: triggerPayloadPacket.dataType,
2665+
payloadSize,
25692666
delay: options?.delay,
25702667
ttl: options?.ttl,
25712668
tags: options?.tags,
@@ -3070,14 +3167,18 @@ async function prepareTriggerPayload(
30703167
payload: unknown,
30713168
apiClient: ApiClient,
30723169
taskId: string
3073-
): Promise<IOPacket> {
3170+
): Promise<{ packet: IOPacket; payloadSize: number }> {
30743171
const payloadPacket = await stringifyIO(payload);
3075-
return await conditionallyExportPacket(
3172+
// Measure the serialized size before any offload, so it reflects the real payload
3173+
// size rather than the small "application/store" reference we may send instead.
3174+
const { size: payloadSize } = packetRequiresOffloading(payloadPacket);
3175+
const packet = await conditionallyExportPacket(
30763176
payloadPacket,
30773177
createTriggerPayloadPathPrefix(taskId),
30783178
undefined,
30793179
apiClient
30803180
);
3181+
return { packet, payloadSize };
30813182
}
30823183

30833184
function createTriggerPayloadPathPrefix(taskId: string): string {

0 commit comments

Comments
 (0)