Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/large-batch-payloads-and-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---

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.
6 changes: 5 additions & 1 deletion apps/webapp/app/runEngine/concerns/payloads.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export class DefaultPayloadProcessor implements PayloadProcessor {
);

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

if (!needsOffloading) {
return packet;
Expand Down
45 changes: 44 additions & 1 deletion packages/core/src/v3/schemas/api-type.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { InitializeDeploymentRequestBody, TriggerTaskRequestBody } from "./api.js";
import { BatchItemNDJSON, InitializeDeploymentRequestBody, TriggerTaskRequestBody } from "./api.js";
import type { InitializeDeploymentRequestBody as InitializeDeploymentRequestBodyType } from "./api.js";

describe("InitializeDeploymentRequestBody", () => {
Expand Down Expand Up @@ -176,4 +176,47 @@ describe("TriggerTaskRequestBody", () => {

expect(result.success).toBe(false);
});

it("accepts an optional payloadSize on the options", () => {
const result = TriggerTaskRequestBody.safeParse({
payload: "packets/payloads/file.json",
context: {},
options: {
payloadType: "application/store",
payloadSize: 512 * 1024,
},
});

expect(result.success).toBe(true);
expect(result.success && result.data.options?.payloadSize).toBe(512 * 1024);
});

it("rejects a negative payloadSize", () => {
const result = TriggerTaskRequestBody.safeParse({
payload: { foo: "bar" },
context: {},
options: {
payloadSize: -1,
},
});

expect(result.success).toBe(false);
});
});

describe("BatchItemNDJSON", () => {
it("accepts an optional payloadSize on a batch item's options", () => {
const result = BatchItemNDJSON.safeParse({
index: 0,
task: "my-task",
payload: "packets/payloads/file.json",
options: {
payloadType: "application/store",
payloadSize: 256 * 1024,
},
});

expect(result.success).toBe(true);
expect(result.success && result.data.options?.payloadSize).toBe(256 * 1024);
});
});
8 changes: 8 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ export const TriggerTaskRequestBody = z
metadata: z.any(),
metadataType: z.string().optional(),
payloadType: z.string().optional(),
/**
* Byte size of the (pre-offload) serialized payload, measured by the caller
* before any object-store offload. Lets the pipeline know how large a payload
* is without downloading an "application/store" reference.
*/
payloadSize: z.number().int().nonnegative().optional(),
tags: RunTags.optional(),
test: z.boolean().optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
Expand Down Expand Up @@ -310,6 +316,8 @@ export const BatchTriggerTaskItem = z.object({
metadataType: z.string().optional(),
parentAttempt: z.string().optional(),
payloadType: z.string().optional(),
/** Byte size of the (pre-offload) serialized payload for this item. */
payloadSize: z.number().int().nonnegative().optional(),
queue: z
.object({
name: z.string(),
Expand Down
54 changes: 53 additions & 1 deletion packages/trigger-sdk/src/v3/shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,57 @@
import { ApiClient } from "@trigger.dev/core/v3";
import { describe, it, expect } from "vitest";
import { readableStreamToAsyncIterable } from "./shared.js";
import { offloadBatchItemPayloads, readableStreamToAsyncIterable } from "./shared.js";

describe("offloadBatchItemPayloads", () => {
// A real client is required for conditionallyExportPacket's truthy check; small payloads
// short-circuit before any network call, so this never actually reaches the server.
const apiClient = new ApiClient("http://localhost:3030", "tr_dev_test");

it("returns an empty array unchanged", async () => {
expect(await offloadBatchItemPayloads([], apiClient)).toEqual([]);
});

it("passes small payloads through and records their pre-offload byte size", async () => {
const payload = JSON.stringify({ hello: "world" });
const result = await offloadBatchItemPayloads(
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
apiClient
);
const item = result[0]!;

expect(item.payload).toBe(payload);
expect(item.options?.payloadType).toBe("application/json");
expect(item.options?.payloadSize).toBe(Buffer.byteLength(payload, "utf8"));
});

it("measures multi-byte payloads by byte length, not character count", async () => {
const payload = "€€€"; // 3 chars, 9 bytes in UTF-8
const result = await offloadBatchItemPayloads(
[{ index: 0, task: "my-task", payload, options: { payloadType: "application/json" } }],
apiClient
);

expect(result[0]!.options?.payloadSize).toBe(9);
});

it("leaves an already-offloaded (application/store) item untouched", async () => {
const item = {
index: 0,
task: "my-task",
payload: "trigger/my-task/123/payload.json",
options: { payloadType: "application/store" },
};

const result = await offloadBatchItemPayloads([item], apiClient);
expect(result[0]).toEqual(item);
});

it("leaves items without a string payload untouched", async () => {
const item = { index: 0, task: "my-task", options: {} };
const result = await offloadBatchItemPayloads([item], apiClient);
expect(result[0]).toEqual(item);
});
});

describe("readableStreamToAsyncIterable", () => {
it("yields all values from the stream", async () => {
Expand Down
124 changes: 117 additions & 7 deletions packages/trigger-sdk/src/v3/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getSchemaParseFn,
lifecycleHooks,
makeIdempotencyKey,
packetRequiresOffloading,
parsePacket,
RateLimitError,
resourceCatalog,
Expand Down Expand Up @@ -1655,6 +1656,21 @@ async function executeBatchTwoPhase(
},
requestOptions?: TriggerApiRequestOptions
): Promise<{ id: string; runCount: number; publicAccessToken: string; taskIdentifiers: string[] }> {
// Offload any oversized per-item payloads to object storage before streaming, so a batch
// of large items keeps the request body small — the same treatment single triggers get.
// Both the array and streaming batch paths funnel through here, so this is the one place
// batch offloading needs to happen. Wrap failures so a presign/upload error carries the
// same batch context (phase, itemCount, rate-limit info) as the create/stream phases.
try {
items = await offloadBatchItemPayloads(items, apiClient);
} catch (error) {
throw new BatchTriggerError(`Failed to offload payloads for batch with ${items.length} items`, {
cause: error,
phase: "offload",
itemCount: items.length,
});
}

let batch: Awaited<ReturnType<typeof apiClient.createBatch>> | undefined;

try {
Expand Down Expand Up @@ -1701,6 +1717,81 @@ async function executeBatchTwoPhase(
};
}

/**
* Offload any oversized per-item payloads in a batch to object storage, mirroring the
* single-trigger offload path. Small payloads pass through untouched. Every returned item
* carries `options.payloadSize` (the pre-offload serialized byte size) so the pipeline can
* reason about payload size without downloading an "application/store" reference.
*
* Uploads run with bounded concurrency so a batch of large items doesn't fire an unbounded
* number of simultaneous presigned PUTs.
*
* @internal Exported for testing.
*/
export async function offloadBatchItemPayloads(
items: BatchItemNDJSON[],
apiClient: ApiClient,
concurrency = 10
): Promise<BatchItemNDJSON[]> {
if (items.length === 0) {
return items;
}

const results = new Array<BatchItemNDJSON>(items.length);
let cursor = 0;

async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await offloadBatchItemPayload(items[index]!, apiClient);
}
}

await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));

return results;
}

async function offloadBatchItemPayload(
item: BatchItemNDJSON,
apiClient: ApiClient
): Promise<BatchItemNDJSON> {
// The batch builders serialize each payload to a string via stringifyIO before we get
// here; anything else has no inline body to offload or measure.
if (typeof item.payload !== "string" || item.payload.length === 0) {
return item;
}

const dataType = item.options?.payloadType ?? "application/json";

// Already an object-store reference — the caller pre-offloaded it, nothing to do.
if (dataType === "application/store") {
return item;
}

const packet: IOPacket = { data: item.payload, dataType };
// Measure before offload so the size reflects the real payload, not the small reference
// we may replace it with.
const { size: payloadSize } = packetRequiresOffloading(packet);

const exported = await conditionallyExportPacket(
packet,
createTriggerPayloadPathPrefix(item.task),
undefined,
apiClient
);

return {
...item,
payload: exported.data,
options: {
...item.options,
payloadType: exported.dataType,
payloadSize,
},
};
}

/**
* Error thrown when batch trigger operations fail.
* Includes context about which phase failed and the batch details.
Expand All @@ -1710,7 +1801,7 @@ async function executeBatchTwoPhase(
* - `retryAfterMs`: milliseconds until the rate limit resets
*/
export class BatchTriggerError extends Error {
readonly phase: "create" | "stream";
readonly phase: "offload" | "create" | "stream";
readonly batchId?: string;
readonly itemCount: number;

Expand All @@ -1730,7 +1821,7 @@ export class BatchTriggerError extends Error {
message: string,
options: {
cause?: unknown;
phase: "create" | "stream";
phase: "offload" | "create" | "stream";
batchId?: string;
itemCount: number;
}
Expand Down Expand Up @@ -2205,7 +2296,11 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);

const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
parsedPayload,
apiClient,
id
);

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

const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
parsedPayload,
apiClient,
id
);

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

const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
const triggerPayloadPacket = await prepareTriggerPayload(parsedPayload, apiClient, id);
const { packet: triggerPayloadPacket, payloadSize } = await prepareTriggerPayload(
parsedPayload,
apiClient,
id
);

const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey);
const idempotencyKeyOptions = processedIdempotencyKey
Expand All @@ -2566,6 +2671,7 @@ async function triggerAndSubscribe_internal<TIdentifier extends string, TPayload
concurrencyKey: options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: triggerPayloadPacket.dataType,
payloadSize,
delay: options?.delay,
ttl: options?.ttl,
tags: options?.tags,
Expand Down Expand Up @@ -3070,14 +3176,18 @@ async function prepareTriggerPayload(
payload: unknown,
apiClient: ApiClient,
taskId: string
): Promise<IOPacket> {
): Promise<{ packet: IOPacket; payloadSize: number }> {
const payloadPacket = await stringifyIO(payload);
return await conditionallyExportPacket(
// Measure the serialized size before any offload, so it reflects the real payload
// size rather than the small "application/store" reference we may send instead.
const { size: payloadSize } = packetRequiresOffloading(payloadPacket);
const packet = await conditionallyExportPacket(
payloadPacket,
createTriggerPayloadPathPrefix(taskId),
undefined,
apiClient
);
return { packet, payloadSize };
}

function createTriggerPayloadPathPrefix(taskId: string): string {
Expand Down
Loading