193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
import { type IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
|
import pRetry from "p-retry";
|
|
import { env } from "~/env.server";
|
|
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { hasObjectStoreClient, uploadPacketToObjectStore } from "~/v3/objectStore.server";
|
|
import { startActiveSpan } from "~/v3/tracer.server";
|
|
|
|
export type BatchPayloadProcessResult = {
|
|
/** The processed payload - either the original or an R2 path */
|
|
payload: unknown;
|
|
/** The payload type - "application/store" if offloaded to R2 */
|
|
payloadType: string;
|
|
/** Whether the payload was offloaded to R2 */
|
|
wasOffloaded: boolean;
|
|
/** Size of the payload in bytes */
|
|
size: number;
|
|
};
|
|
|
|
/**
|
|
* BatchPayloadProcessor handles payload offloading for batch items.
|
|
*
|
|
* When a batch item's payload exceeds the configured threshold, it's uploaded
|
|
* to object storage (R2) and the payload is replaced with the storage path.
|
|
* This aligns with how single task triggers work via DefaultPayloadProcessor.
|
|
*
|
|
* Path format: batch_{batchId}/item_{index}/payload.json
|
|
*/
|
|
export class BatchPayloadProcessor {
|
|
/**
|
|
* Check if object storage is available for payload offloading.
|
|
* If not available, large payloads will be stored inline (which may fail for very large payloads).
|
|
*/
|
|
isObjectStoreAvailable(): boolean {
|
|
return hasObjectStoreClient();
|
|
}
|
|
|
|
/**
|
|
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
|
|
*
|
|
* @param payload - The raw payload from the batch item
|
|
* @param payloadType - The payload type (e.g., "application/json")
|
|
* @param batchId - The batch ID (internal format)
|
|
* @param itemIndex - The item index within the batch
|
|
* @param environment - The authenticated environment for R2 path construction
|
|
* @returns The processed result with potentially offloaded payload
|
|
*/
|
|
async process(
|
|
payload: unknown,
|
|
payloadType: string,
|
|
batchId: string,
|
|
itemIndex: number,
|
|
environment: AuthenticatedEnvironment
|
|
): Promise<BatchPayloadProcessResult> {
|
|
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
|
|
span.setAttribute("batchId", batchId);
|
|
span.setAttribute("itemIndex", itemIndex);
|
|
span.setAttribute("payloadType", payloadType);
|
|
|
|
// Create the packet for size checking
|
|
const packet = this.#createPayloadPacket(payload, payloadType);
|
|
|
|
if (!packet.data) {
|
|
return {
|
|
payload,
|
|
payloadType,
|
|
wasOffloaded: false,
|
|
size: 0,
|
|
};
|
|
}
|
|
|
|
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
|
|
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
|
|
|
|
span.setAttribute("payloadSize", size);
|
|
span.setAttribute("needsOffloading", needsOffloading);
|
|
span.setAttribute("threshold", threshold);
|
|
|
|
if (!needsOffloading) {
|
|
return {
|
|
payload,
|
|
payloadType,
|
|
wasOffloaded: false,
|
|
size,
|
|
};
|
|
}
|
|
|
|
// Check if object store is available
|
|
if (!this.isObjectStoreAvailable()) {
|
|
logger.warn("Payload exceeds threshold but object store is not available", {
|
|
batchId,
|
|
itemIndex,
|
|
size,
|
|
threshold,
|
|
});
|
|
|
|
// Return without offloading - the payload will be stored inline
|
|
// This may fail downstream for very large payloads
|
|
return {
|
|
payload,
|
|
payloadType,
|
|
wasOffloaded: false,
|
|
size,
|
|
};
|
|
}
|
|
|
|
// Upload to object store, retrying on transient network errors
|
|
const { data: packetData, dataType: packetDataType } = packet;
|
|
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
|
|
|
|
const [uploadError, uploadedFilename] = await tryCatch(
|
|
pRetry(
|
|
() =>
|
|
uploadPacketToObjectStore(
|
|
filename,
|
|
packetData,
|
|
packetDataType,
|
|
environment,
|
|
env.OBJECT_STORE_DEFAULT_PROTOCOL
|
|
),
|
|
{
|
|
retries: 3,
|
|
minTimeout: 500,
|
|
maxTimeout: 2000,
|
|
factor: 2,
|
|
onFailedAttempt: (error) => {
|
|
logger.warn("Batch item payload upload to object store failed, retrying", {
|
|
batchId,
|
|
itemIndex,
|
|
attempt: error.attemptNumber,
|
|
retriesLeft: error.retriesLeft,
|
|
error: error.message,
|
|
});
|
|
},
|
|
}
|
|
)
|
|
);
|
|
|
|
if (uploadError) {
|
|
logger.error("Failed to upload batch item payload to object store after retries", {
|
|
batchId,
|
|
itemIndex,
|
|
error: uploadError.message,
|
|
});
|
|
|
|
throw new Error(`Failed to upload large payload to object store: ${uploadError.message}`);
|
|
}
|
|
|
|
logger.debug("Batch item payload offloaded to object store", {
|
|
batchId,
|
|
itemIndex,
|
|
filename: uploadedFilename,
|
|
size,
|
|
});
|
|
|
|
span.setAttribute("wasOffloaded", true);
|
|
span.setAttribute("offloadPath", uploadedFilename);
|
|
|
|
return {
|
|
payload: uploadedFilename!,
|
|
payloadType: "application/store",
|
|
wasOffloaded: true,
|
|
size,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create an IOPacket from payload for size checking.
|
|
*/
|
|
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
|
|
if (payloadType === "application/json") {
|
|
// Payload from SDK is already serialized as a string - use directly
|
|
if (typeof payload === "string") {
|
|
return { data: payload, dataType: "application/json" };
|
|
}
|
|
// Non-string payloads (e.g., direct API calls with objects) need serialization
|
|
return { data: JSON.stringify(payload), dataType: "application/json" };
|
|
}
|
|
|
|
if (typeof payload === "string") {
|
|
return { data: payload, dataType: payloadType };
|
|
}
|
|
|
|
// For other types, try to stringify
|
|
try {
|
|
return { data: JSON.stringify(payload), dataType: payloadType };
|
|
} catch {
|
|
return { dataType: payloadType };
|
|
}
|
|
}
|
|
}
|