chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,837 @@
import {
type StreamBatchItemsResponse,
BatchItemNDJSON as BatchItemNDJSONSchema,
} from "@trigger.dev/core/v3";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import type { BatchItem, RunEngine } from "@internal/run-engine";
import pMap from "p-map";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
/**
* Phase 2 retry idempotency check.
*
* Returns true when the batch is in a state that means the Phase 2 stream's
* job has already been done — every item has a TaskRun record (real or
* pre-failed) for the customer to monitor. A retry, or the original call
* racing against a fast-completing BatchQueue, should return sealed:true
* in these states so the SDK stops retrying.
*
* Three "work is done" shapes:
* - status moved out of PENDING into PROCESSING/COMPLETED/PARTIAL_FAILED
* (PROCESSING via our seal, COMPLETED via tryCompleteBatch, PARTIAL_FAILED
* via the V2 batchCompletionCallback).
* - status stuck at PENDING but `sealed=true`: another concurrent
* streamBatchItems call sealed the batch and then the callback's
* happy-path branch reset status to PENDING ("all runs created").
* - status stuck at PENDING with `sealed=false` but `processingCompletedAt`
* set: the cleanup-race. BatchQueue rushed through all items, callback
* fired (setting processingCompletedAt), cleanup deleted the Redis
* metadata — all before our service got the chance to seal. The work
* is done; the discriminator is processingCompletedAt which is set
* exclusively by the V2 completion callback.
*
* ABORTED is excluded — it means ZERO TaskRun records were created (every
* per-item attempt failed AND the pre-failed-TaskRun fallback also failed,
* or queue-overload on every item). The customer has nothing to monitor
* at the run level, so the trigger call must throw to give their retry/
* error handling a chance to create a fresh batch.
*/
export function isIdempotentRetrySuccess(
status: BatchTaskRunStatus | null | undefined,
sealed: boolean | null | undefined,
processingCompletedAt: Date | null | undefined
): boolean {
return (
status === "PROCESSING" ||
status === "COMPLETED" ||
status === "PARTIAL_FAILED" ||
(status === "PENDING" && (sealed === true || processingCompletedAt != null))
);
}
export type StreamBatchItemsServiceOptions = {
maxItemBytes: number;
/** Max items processed concurrently. The route wires this to STREAMING_BATCH_INGEST_CONCURRENCY. */
concurrency: number;
};
export type OversizedItemMarker = {
__batchItemError: "OVERSIZED";
index: number;
task: string;
actualSize: number;
maxSize: number;
};
export type StreamBatchItemsServiceConstructorOptions = {
prisma?: PrismaClientOrTransaction;
engine?: RunEngine;
/** Override the payload processor (used in tests to observe ingest concurrency). */
payloadProcessor?: BatchPayloadProcessor;
};
/**
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
*
* This service handles Phase 2 of the streaming batch API:
* 1. Validates batch exists and is in PENDING status
* 2. Processes NDJSON stream item by item
* 3. Calls engine.enqueueBatchItem() for each item
* 4. Tracks accepted/deduplicated counts
* 5. On completion: validates count, seals the batch
*
* The service is designed for streaming and processes items as they arrive,
* providing backpressure through the async iterator pattern.
*/
export class StreamBatchItemsService extends WithRunEngine {
private readonly payloadProcessor: BatchPayloadProcessor;
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
this.payloadProcessor = opts.payloadProcessor ?? new BatchPayloadProcessor();
}
/**
* Parse a batch friendly ID to its internal ID format.
* Throws a ServiceValidationError with 400 status if the ID is malformed.
*/
private parseBatchFriendlyId(friendlyId: string): string {
try {
return BatchId.fromFriendlyId(friendlyId);
} catch {
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
}
}
/**
* Process a stream of batch items from an async iterator.
* Each item is validated and enqueued to the BatchQueue.
* The batch is sealed when the stream completes.
*/
public async call(
environment: AuthenticatedEnvironment,
batchFriendlyId: string,
itemsIterator: AsyncIterable<unknown>,
options: StreamBatchItemsServiceOptions
): Promise<StreamBatchItemsResponse> {
return this.traceWithEnv<StreamBatchItemsResponse>(
"streamBatchItems()",
environment,
async (span) => {
span.setAttribute("batchId", batchFriendlyId);
// Convert friendly ID to internal ID
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
// Validate batch exists and belongs to this environment. Routed by batch id so a
// run-ops id (NEW-resident) batch is found on the owning DB; the env-ownership check that
// was in the where clause is enforced app-side below.
const batch = await this._engine.runStore.findBatchTaskRunById(batchId);
if (!batch || batch.runtimeEnvironmentId !== environment.id) {
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
}
if (isIdempotentRetrySuccess(batch.status, batch.sealed, batch.processingCompletedAt)) {
logger.info("Batch already sealed/completed - treating Phase 2 retry as success", {
batchId: batchFriendlyId,
batchSealed: batch.sealed,
batchStatus: batch.status,
processingCompletedAt: batch.processingCompletedAt,
});
return {
id: batchFriendlyId,
itemsAccepted: 0,
itemsDeduplicated: 0,
sealed: true,
runCount: batch.runCount,
};
}
if (batch.status !== "PENDING") {
// ABORTED or any other unexpected non-PENDING state — surface as an error.
// For ABORTED specifically, throwing is required so the customer's
// batchTrigger() retries (a new batch) can recreate the runs.
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
);
}
// Process items from the stream with bounded concurrency.
//
// Ordering and idempotency do NOT depend on processing order:
// - The BatchQueue derives run order from each item's index
// (enqueue timestamp = batch.createdAt + itemIndex), not enqueue order.
// - enqueueBatchItem() dedups atomically per index.
// We cap concurrency to bound peak in-flight memory (≈ concurrency ×
// maxItemBytes) and to keep backpressure on the request body stream.
// p-map pulls lazily from the async iterator — at most `concurrency`
// items are read and in flight at once. stopOnError aborts ingestion on
// the first failure (the batch is left unsealed; the SDK's retry
// re-streams and dedups already-enqueued items).
const outcomes = await pMap(
itemsIterator,
(rawItem) => this.#processItem(rawItem, batchId, environment, batch.runCount),
{ concurrency: options.concurrency, stopOnError: true }
);
let itemsAccepted = 0;
let itemsDeduplicated = 0;
for (const outcome of outcomes) {
if (outcome === "accepted") {
itemsAccepted++;
} else {
itemsDeduplicated++;
}
}
// Get the actual enqueued count from Redis
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
// Validate we received the expected number of items
if (enqueuedCount !== batch.runCount) {
// The batch queue consumers may have already processed all items and
// cleaned up the Redis keys before we got here. This happens when all
// runs complete fast enough that cleanup() deletes the enqueuedItemsKey
// before we read it — typically when the last item executes in the
// milliseconds between the loop ending and getBatchEnqueuedCount() being called.
// Check both sealed (sealed by this endpoint on a concurrent request) and
// COMPLETED (sealed by the BatchQueue completion path before we got here).
const currentBatch = await this._engine.runStore.findBatchTaskRunById(batchId);
if (
isIdempotentRetrySuccess(
currentBatch?.status,
currentBatch?.sealed,
currentBatch?.processingCompletedAt
)
) {
logger.info("Batch already sealed before count check (fast completion)", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
enqueuedCount,
expectedCount: batch.runCount,
batchStatus: currentBatch?.status,
processingCompletedAt: currentBatch?.processingCompletedAt,
});
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
if (currentBatch?.status === "ABORTED") {
// Zero TaskRuns exist — the count-mismatch sealed:false semantics
// ("retry with missing items") would mislead the SDK. Throw so the
// customer's batchTrigger() retry creates a fresh batch.
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is not in PENDING status (current: ABORTED)`
);
}
logger.warn("Batch item count mismatch", {
batchId: batchFriendlyId,
expected: batch.runCount,
received: enqueuedCount,
itemsAccepted,
itemsDeduplicated,
});
// Don't seal the batch if count doesn't match
// Return sealed: false so client knows to retry with missing items
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: false,
enqueuedCount,
expectedCount: batch.runCount,
runCount: batch.runCount,
};
}
// Seal the batch - use conditional update to prevent TOCTOU race
// Another concurrent request may have already sealed this batch
const now = new Date();
const sealResult = await this._engine.runStore.updateManyBatchTaskRun({
where: {
id: batchId,
sealed: false,
status: "PENDING",
},
data: {
sealed: true,
sealedAt: now,
status: "PROCESSING",
processingStartedAt: now,
},
});
// Check if we won the race to seal the batch
if (sealResult.count === 0) {
// The conditional update failed because the batch was no longer in
// PENDING status. Re-query to determine which path got there first:
// - A concurrent streaming request already sealed and moved it to
// PROCESSING.
// - The BatchQueue completion path finished all runs and set it to
// COMPLETED (without setting sealed=true — that's this endpoint's
// job). This window exists between completionCallback (which calls
// tryCompleteBatch) and cleanup() in BatchQueue — see
// batch-queue/index.ts.
// Either way the goal — a durable batch that the SDK stops retrying —
// has been achieved, so we return sealed: true.
const currentBatch = await this._engine.runStore.findBatchTaskRunById(batchId);
if (
isIdempotentRetrySuccess(
currentBatch?.status,
currentBatch?.sealed,
currentBatch?.processingCompletedAt
)
) {
logger.info("Batch already sealed/completed by concurrent path", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
envId: environment.id,
batchStatus: currentBatch?.status,
batchSealed: currentBatch?.sealed,
processingCompletedAt: currentBatch?.processingCompletedAt,
});
span.setAttribute("itemsAccepted", itemsAccepted);
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
span.setAttribute("sealedByConcurrentRequest", true);
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
// Batch is in an unexpected state - fail with error
const actualStatus = currentBatch?.status ?? "unknown";
const actualSealed = currentBatch?.sealed ?? "unknown";
logger.error("Batch seal race condition: unexpected state", {
batchId: batchFriendlyId,
expectedStatus: "PENDING",
actualStatus,
expectedSealed: false,
actualSealed,
envId: environment.id,
});
throw new ServiceValidationError(
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
);
}
logger.info("Batch sealed and ready for processing", {
batchId: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
totalEnqueued: enqueuedCount,
envId: environment.id,
});
span.setAttribute("itemsAccepted", itemsAccepted);
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
return {
id: batchFriendlyId,
itemsAccepted,
itemsDeduplicated,
sealed: true,
runCount: batch.runCount,
};
}
);
}
/**
* Process a single streamed batch item: validate it, offload its payload to
* object storage if oversized, and enqueue it. Returns whether the item was
* newly enqueued ("accepted") or was a duplicate ("deduplicated"). Throws
* ServiceValidationError for invalid items, which aborts the stream.
*
* Safe to run concurrently: enqueueBatchItem() is atomic and order-independent
* per item index, and each item carries its own index (real items from the
* SDK; oversized markers are stamped by the NDJSON parser).
*/
async #processItem(
rawItem: unknown,
batchId: string,
environment: AuthenticatedEnvironment,
runCount: number
): Promise<"accepted" | "deduplicated"> {
// Oversized item marker emitted by the NDJSON parser
if (rawItem && typeof rawItem === "object" && "__batchItemError" in rawItem) {
const marker = rawItem as OversizedItemMarker;
// Same out-of-range guard as normal items: an oversized item with an
// out-of-range index must 4xx rather than create a stray pre-failed run.
if (marker.index >= runCount) {
throw new ServiceValidationError(
`Item index ${marker.index} exceeds batch runCount ${runCount}`
);
}
const errorMessage = `Batch item payload is too large (${(marker.actualSize / 1024).toFixed(
1
)} KB). Maximum allowed size is ${(marker.maxSize / 1024).toFixed(
1
)} KB. Reduce the payload size or offload large data to external storage.`;
// Enqueue with __error metadata - processItemCallback will detect this
// and use TriggerFailedTaskService to create a pre-failed run
const batchItem: BatchItem = {
task: marker.task,
payload: "{}",
payloadType: "application/json",
options: {
__error: errorMessage,
__errorCode: "PAYLOAD_TOO_LARGE",
},
};
const result = await this._engine.enqueueBatchItem(
batchId,
environment.id,
marker.index,
batchItem
);
return result.enqueued ? "accepted" : "deduplicated";
}
// Parse and validate the item
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
if (!parseResult.success) {
const rawIndex = (rawItem as { index?: unknown } | null)?.index;
const where = typeof rawIndex === "number" ? `index ${rawIndex}` : "unknown index";
throw new ServiceValidationError(`Invalid item at ${where}: ${parseResult.error.message}`);
}
const item = parseResult.data;
// Validate index is within expected range
if (item.index >= runCount) {
throw new ServiceValidationError(
`Item index ${item.index} exceeds batch runCount ${runCount}`
);
}
// Get the original payload type
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
// Process payload - offload to object storage if it exceeds threshold
const processedPayload = await this.payloadProcessor.process(
item.payload,
originalPayloadType,
batchId,
item.index,
environment
);
// Convert to BatchItem format with potentially offloaded payload
const batchItem: BatchItem = {
task: item.task,
payload: processedPayload.payload,
payloadType: processedPayload.payloadType,
options: item.options,
};
// Enqueue the item
const result = await this._engine.enqueueBatchItem(
batchId,
environment.id,
item.index,
batchItem
);
return result.enqueued ? "accepted" : "deduplicated";
}
}
/**
* Extract `index` and `task` from raw JSON bytes without decoding the full line.
* Scans at most 512 bytes, tracking JSON nesting depth to only match top-level keys.
*/
export function extractIndexAndTask(bytes: Uint8Array): { index: number; task: string } {
let index = -1;
let task = "unknown";
let depth = 0;
let foundIndex = false;
let foundTask = false;
const limit = Math.min(bytes.byteLength, 512);
const QUOTE = 0x22; // "
const COLON = 0x3a; // :
const LBRACE = 0x7b; // {
const RBRACE = 0x7d; // }
const LBRACKET = 0x5b; // [
const RBRACKET = 0x5d; // ]
const BACKSLASH = 0x5c; // \
// Byte patterns for "index" and "task" (without quotes)
const INDEX_BYTES = [0x69, 0x6e, 0x64, 0x65, 0x78]; // index
const TASK_BYTES = [0x74, 0x61, 0x73, 0x6b]; // task
let i = 0;
while (i < limit && !(foundIndex && foundTask)) {
const b = bytes[i];
if (b === LBRACE || b === LBRACKET) {
depth++;
i++;
continue;
}
if (b === RBRACE || b === RBRACKET) {
depth--;
i++;
continue;
}
// Only match keys at depth 1 (top-level object)
if (b === QUOTE && depth === 1) {
// Read the key inside quotes
const keyStart = i + 1;
let keyEnd = keyStart;
while (keyEnd < limit && bytes[keyEnd] !== QUOTE) {
if (bytes[keyEnd] === BACKSLASH) keyEnd++; // skip escaped char
keyEnd++;
}
const keyLen = keyEnd - keyStart;
// Check if this key matches "index" or "task"
const isIndex =
!foundIndex &&
keyLen === INDEX_BYTES.length &&
INDEX_BYTES.every((b, j) => bytes[keyStart + j] === b);
const isTask =
!foundTask &&
keyLen === TASK_BYTES.length &&
TASK_BYTES.every((b, j) => bytes[keyStart + j] === b);
if (isIndex || isTask) {
// Skip past closing quote and find colon
let pos = keyEnd + 1;
while (pos < limit && bytes[pos] !== COLON) pos++;
pos++; // skip colon
// Skip whitespace
while (pos < limit && (bytes[pos] === 0x20 || bytes[pos] === 0x09)) pos++;
if (isIndex) {
// Parse digits
let num = 0;
let hasDigit = false;
while (pos < limit && bytes[pos] >= 0x30 && bytes[pos] <= 0x39) {
num = num * 10 + (bytes[pos] - 0x30);
hasDigit = true;
pos++;
}
if (hasDigit) {
index = num;
foundIndex = true;
}
} else {
// Parse quoted string value
if (pos < limit && bytes[pos] === QUOTE) {
const valStart = pos + 1;
let valEnd = valStart;
while (valEnd < limit && bytes[valEnd] !== QUOTE) {
if (bytes[valEnd] === BACKSLASH) valEnd++;
valEnd++;
}
// Decode just this slice
try {
task = new TextDecoder("utf-8", { fatal: true }).decode(
bytes.slice(valStart, valEnd)
);
foundTask = true;
} catch {
// Leave as "unknown"
}
}
}
}
// Skip past the key's closing quote
i = keyEnd + 1;
continue;
}
i++;
}
return { index, task };
}
/**
* Create an NDJSON parser transform stream.
*
* Converts a stream of Uint8Array chunks into parsed JSON objects.
* Each line in the NDJSON is parsed independently.
*
* Uses byte-buffer accumulation to:
* - Prevent OOM from unbounded string buffers
* - Properly handle multibyte UTF-8 characters across chunk boundaries
* - Check size limits on raw bytes before decoding
*
* @param maxItemBytes - Maximum allowed bytes per line (item)
* @returns TransformStream that outputs parsed JSON objects
*/
export function createNdjsonParserStream(
maxItemBytes: number
): TransformStream<Uint8Array, unknown> {
// Single decoder instance, reused for all lines
const decoder = new TextDecoder("utf-8", { fatal: true });
// Byte buffer: array of chunks with tracked total length
let chunks: Uint8Array[] = [];
let totalBytes = 0;
let lineNumber = 0;
// 0-based position of the next object we emit (parsed item or oversized
// marker). The parser is the single sequential point in the pipeline, so this
// is the authoritative source of item ordering — downstream consumers can
// process items concurrently and must not rely on processing order to derive
// an item's index. Used to back-fill an oversized marker's index when it
// couldn't be extracted from the (truncated) raw bytes.
let emittedCount = 0;
// When an oversized incomplete line is detected (Case 2), we must discard
// all remaining bytes of that line until the next newline delimiter.
let skipUntilNewline = false;
const NEWLINE_BYTE = 0x0a; // '\n'
/**
* Emit a parsed object or marker downstream and advance the emit position.
* Every emitted object MUST go through here so `emittedCount` stays aligned
* with item position (empty/skipped lines never emit, so they don't count).
*/
function emit(controller: TransformStreamDefaultController<unknown>, obj: unknown): void {
controller.enqueue(obj);
emittedCount++;
}
/**
* Concatenate all chunks into a single Uint8Array
*/
function concatenateChunks(): Uint8Array {
if (chunks.length === 0) {
return new Uint8Array(0);
}
if (chunks.length === 1) {
return chunks[0];
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
/**
* Find the index of the first newline byte in the buffer.
* Returns -1 if not found.
*/
function findNewlineIndex(): number {
let globalIndex = 0;
for (const chunk of chunks) {
for (let i = 0; i < chunk.byteLength; i++) {
if (chunk[i] === NEWLINE_BYTE) {
return globalIndex + i;
}
}
globalIndex += chunk.byteLength;
}
return -1;
}
/**
* Extract bytes from the buffer up to (but not including) the given index,
* and remove those bytes plus the delimiter from the buffer.
*/
function extractLine(newlineIndex: number): Uint8Array {
const fullBuffer = concatenateChunks();
const lineBytes = fullBuffer.slice(0, newlineIndex);
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
// Reset buffer with remaining bytes
if (remaining.byteLength > 0) {
chunks = [remaining];
totalBytes = remaining.byteLength;
} else {
chunks = [];
totalBytes = 0;
}
return lineBytes;
}
/**
* Parse a line from bytes, handling whitespace trimming.
* Returns the parsed object or null for empty lines.
*/
function parseLine(
lineBytes: Uint8Array,
controller: TransformStreamDefaultController<unknown>
): void {
lineNumber++;
// Decode the line bytes (stream: false since this is a complete line)
let lineText: string;
try {
lineText = decoder.decode(lineBytes, { stream: false });
} catch (err) {
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
}
const trimmed = lineText.trim();
if (!trimmed) {
return; // Skip empty lines
}
try {
const obj = JSON.parse(trimmed);
emit(controller, obj);
} catch (err) {
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
}
}
return new TransformStream<Uint8Array, unknown>({
transform(chunk, controller) {
// If we're skipping the remainder of an oversized line, scan for the
// next newline in this chunk and discard everything before it.
if (skipUntilNewline) {
const nlPos = chunk.indexOf(NEWLINE_BYTE);
if (nlPos === -1) {
// Entire chunk is still part of the oversized line — discard it
return;
}
// Found the newline — keep everything after it
skipUntilNewline = false;
const remaining = chunk.slice(nlPos + 1);
if (remaining.byteLength === 0) {
return;
}
// Replace chunk with the remainder and fall through to normal processing
chunk = remaining;
}
// Append chunk to buffer
chunks.push(chunk);
totalBytes += chunk.byteLength;
// Process all complete lines in the buffer
let newlineIndex: number;
while ((newlineIndex = findNewlineIndex()) !== -1) {
// Check size limit BEFORE extracting/decoding (bytes up to newline)
if (newlineIndex > maxItemBytes) {
// Case 1: Complete line exceeds limit - emit marker instead of throwing
const lineBytes = extractLine(newlineIndex);
const extracted = extractIndexAndTask(lineBytes);
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index >= 0 ? extracted.index : emittedCount,
task: extracted.task,
actualSize: newlineIndex,
maxSize: maxItemBytes,
};
emit(controller, marker);
lineNumber++;
continue;
}
const lineBytes = extractLine(newlineIndex);
parseLine(lineBytes, controller);
}
// Check if the remaining buffer (incomplete line) exceeds the limit
// This prevents OOM from a single huge line without newlines
if (totalBytes > maxItemBytes) {
// Case 2: Incomplete line exceeds limit - emit marker instead of throwing
const extracted = extractIndexAndTask(concatenateChunks());
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index >= 0 ? extracted.index : emittedCount,
task: extracted.task,
actualSize: totalBytes,
maxSize: maxItemBytes,
};
emit(controller, marker);
lineNumber++;
// Clear buffer and skip remaining bytes of this oversized line
// until the next newline delimiter is found in a subsequent chunk
chunks = [];
totalBytes = 0;
skipUntilNewline = true;
return;
}
},
flush(controller) {
// Flush any remaining bytes from the decoder's internal state
// This handles multibyte characters that may have been split across chunks
decoder.decode(new Uint8Array(0), { stream: false });
// Process any remaining buffered data (no trailing newline case)
if (totalBytes === 0) {
return;
}
// Check size limit before processing final line
if (totalBytes > maxItemBytes) {
// Case 3: Flush with oversized remaining - emit marker instead of throwing
const extracted = extractIndexAndTask(concatenateChunks());
const marker: OversizedItemMarker = {
__batchItemError: "OVERSIZED",
index: extracted.index >= 0 ? extracted.index : emittedCount,
task: extracted.task,
actualSize: totalBytes,
maxSize: maxItemBytes,
};
emit(controller, marker);
return;
}
const finalBytes = concatenateChunks();
parseLine(finalBytes, controller);
},
});
}
/**
* Convert a ReadableStream into an AsyncIterable.
* Useful for processing streams with for-await-of loops.
*/
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
} finally {
reader.releaseLock();
}
}