2359 lines
78 KiB
TypeScript
2359 lines
78 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock the db prisma client - needs to be before other imports
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: {},
|
|
$replica: {},
|
|
}));
|
|
|
|
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
getEntitlement: vi.fn(),
|
|
};
|
|
});
|
|
|
|
import { RunEngine } from "@internal/run-engine";
|
|
import { PostgresRunStore } from "@internal/run-store";
|
|
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
|
|
// Per-test redis isolation: each test runs its own RunEngine whose background work outlives the test
|
|
// body. NoClickhouse because this suite never touches ClickHouse - skips the worker-scoped boot+migrate.
|
|
import { containerTestWithIsolatedRedisNoClickhouse as containerTest } from "@internal/testcontainers";
|
|
import { trace } from "@opentelemetry/api";
|
|
import type { PrismaClient } from "@trigger.dev/database";
|
|
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
|
import {
|
|
StreamBatchItemsService,
|
|
createNdjsonParserStream,
|
|
streamToAsyncIterable,
|
|
extractIndexAndTask,
|
|
type OversizedItemMarker,
|
|
} from "../../app/runEngine/services/streamBatchItems.server";
|
|
import { ServiceValidationError } from "../../app/v3/services/baseService.server";
|
|
import {
|
|
BatchPayloadProcessor,
|
|
type BatchPayloadProcessResult,
|
|
} from "../../app/runEngine/concerns/batchPayloads.server";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
|
|
// 120s: a cold per-test container boot counts against the test's own timeout, and under CI Docker
|
|
// contention 30s was too tight. Matches the run-engine convention for this footprint.
|
|
vi.setConfig({ testTimeout: 120_000 });
|
|
|
|
describe("StreamBatchItemsService", () => {
|
|
async function createBatch(
|
|
prisma: PrismaClient,
|
|
environmentId: string,
|
|
options: {
|
|
runCount: number;
|
|
status?: "PENDING" | "PROCESSING" | "COMPLETED" | "PARTIAL_FAILED" | "ABORTED";
|
|
sealed?: boolean;
|
|
processingCompletedAt?: Date | null;
|
|
}
|
|
) {
|
|
const { id, friendlyId } = BatchId.generate();
|
|
|
|
const batch = await prisma.batchTaskRun.create({
|
|
data: {
|
|
id,
|
|
friendlyId,
|
|
runtimeEnvironmentId: environmentId,
|
|
status: options.status ?? "PENDING",
|
|
runCount: options.runCount,
|
|
expectedCount: options.runCount,
|
|
runIds: [],
|
|
batchVersion: "runengine:v2",
|
|
sealed: options.sealed ?? false,
|
|
processingCompletedAt: options.processingCompletedAt ?? null,
|
|
},
|
|
});
|
|
|
|
return batch;
|
|
}
|
|
|
|
async function* itemsToAsyncIterable(
|
|
items: Array<{ task: string; payload: string; index: number }>
|
|
) {
|
|
for (const item of items) {
|
|
yield item;
|
|
}
|
|
}
|
|
|
|
function makeItems(count: number, taskId = "test-task") {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
task: taskId,
|
|
payload: JSON.stringify({ i: index }),
|
|
index,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Build a RunEngine for tests. The worker is disabled and no item-processing
|
|
* callback is wired, so streamed items stay enqueued (the BatchQueue consumer
|
|
* doesn't drain them) — keeping the enqueued-count assertions deterministic.
|
|
*/
|
|
function buildEngine(prisma: PrismaClient, redisOptions: any) {
|
|
return new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: { redis: redisOptions },
|
|
runLock: { redis: redisOptions },
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: { redis: redisOptions },
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
}
|
|
|
|
containerTest(
|
|
"should seal batch successfully when no race condition",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Create a batch
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
// Initialize the batch in Redis
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Enqueue items directly to Redis (bypassing the service's item processing)
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Create service with our test engine and prisma
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
// Create an empty items iterator since items are already enqueued
|
|
const items = itemsToAsyncIterable([]);
|
|
|
|
const result = await service.call(authenticatedEnvironment, batch.friendlyId, items, {
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
});
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
// Verify the batch is sealed in the database
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.sealed).toBe(true);
|
|
expect(updatedBatch?.status).toBe("PROCESSING");
|
|
expect(updatedBatch?.sealedAt).toBeDefined();
|
|
expect(updatedBatch?.processingStartedAt).toBeDefined();
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is already sealed and PROCESSING (Phase 2 retry idempotency)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Simulate the SDK retrying Phase 2 after the original request succeeded:
|
|
// the original request already sealed the batch and moved it to PROCESSING.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PROCESSING",
|
|
sealed: true,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
// The retry should be treated as success — the original request already
|
|
// enqueued every item, so the SDK should stop retrying.
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
expect(result.itemsAccepted).toBe(0);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
expect(result.runCount).toBe(2);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is COMPLETED before Phase 2 retry arrives (TRI-9944)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// The customer-reported scenario: single-item batch where the original
|
|
// Phase 2 request succeeded server-side, the run executed fast, the batch
|
|
// flipped to COMPLETED, then the lost-response SDK retry hits us.
|
|
// Note: tryCompleteBatch sets status=COMPLETED but does NOT set sealed=true.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 1,
|
|
status: "COMPLETED",
|
|
sealed: false,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
expect(result.itemsAccepted).toBe(0);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should throw when batch is in ABORTED status",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "ABORTED",
|
|
sealed: false,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
await expect(
|
|
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
})
|
|
).rejects.toThrow(ServiceValidationError);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should throw when batch is sealed but ABORTED (callback aborted post-seal must surface as error)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// V2 batch completion callback sets status=ABORTED (failedRunCount > 0 &&
|
|
// successfulRunCount === 0) without touching sealed=true that the seal
|
|
// step previously set. The Phase 2 retry must NOT mask this terminal
|
|
// failure as success — every run failed.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "ABORTED",
|
|
sealed: true,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
await expect(
|
|
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
})
|
|
).rejects.toThrow(ServiceValidationError);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is PARTIAL_FAILED (Phase 2 retry idempotency)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// V2 completion callback sets PARTIAL_FAILED when some run-creation
|
|
// attempts failed but at least one succeeded. The Phase 2 stream itself
|
|
// did its job (items were enqueued and processed), so a retry should
|
|
// see this as terminal success — the per-item failures are visible on
|
|
// the individual run records.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PARTIAL_FAILED",
|
|
sealed: false,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
expect(result.itemsAccepted).toBe(0);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is PARTIAL_FAILED by callback before seal attempt",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Simulate the race where V2's batchCompletionCallback runs between
|
|
// getEnqueuedCount and the seal updateMany — some runs failed to create
|
|
// but at least one succeeded, so callback sets status=PARTIAL_FAILED
|
|
// without setting sealed=true.
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
|
updateMany: async () => {
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
status: "PARTIAL_FAILED",
|
|
},
|
|
});
|
|
return { count: 0 };
|
|
},
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.status).toBe("PARTIAL_FAILED");
|
|
expect(updatedBatch?.sealed).toBe(false);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when concurrent request already sealed the batch during seal attempt",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Create a batch in PENDING state
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
// Initialize the batch in Redis
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Enqueue items
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Create a custom prisma client that simulates a race condition:
|
|
// When updateMany is called on batchTaskRun, it returns count: 0 (as if another request beat us)
|
|
// but the subsequent findUnique shows the batch is sealed and PROCESSING
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
|
updateMany: async () => {
|
|
// Simulate another request winning the race - seal the batch first
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
sealed: true,
|
|
sealedAt: new Date(),
|
|
status: "PROCESSING",
|
|
processingStartedAt: new Date(),
|
|
},
|
|
});
|
|
// Return 0 as if the conditional update failed
|
|
return { count: 0 };
|
|
},
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
// Call the service - it should detect the race and return success since batch is sealed
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
// Should return sealed=true because the batch was sealed (by the "other" request)
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
// Verify the batch is sealed in the database
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.sealed).toBe(true);
|
|
expect(updatedBatch?.status).toBe("PROCESSING");
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is COMPLETED by BatchQueue before seal attempt",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Create a batch in PENDING state
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
// Initialize the batch in Redis
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Enqueue items - the enqueued count check passes but the seal updateMany
|
|
// will race with tryCompleteBatch moving status to COMPLETED.
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Simulate the race where BatchQueue's completionCallback runs
|
|
// tryCompleteBatch between getEnqueuedCount and the seal updateMany.
|
|
// tryCompleteBatch sets status=COMPLETED but NOT sealed=true.
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
|
updateMany: async () => {
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
status: "COMPLETED",
|
|
},
|
|
});
|
|
// The conditional updateMany(where: status="PENDING") would now fail
|
|
return { count: 0 };
|
|
},
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
// The endpoint should accept the COMPLETED state as a success case so the
|
|
// SDK does not retry a batch whose child runs have already finished.
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.status).toBe("COMPLETED");
|
|
// sealed stays false because the BatchQueue completion path does not set
|
|
// it - that's fine, the batch is terminal.
|
|
expect(updatedBatch?.sealed).toBe(false);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should throw error when race condition leaves batch in unexpected state",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Create a batch in PENDING state
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
// Initialize the batch in Redis
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Enqueue items
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Create a custom prisma client that simulates a race condition where
|
|
// the batch ends up in an unexpected state (ABORTED instead of PROCESSING)
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
|
updateMany: async () => {
|
|
// Simulate the batch being aborted by another process
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
sealed: true,
|
|
status: "ABORTED",
|
|
},
|
|
});
|
|
// Return 0 as if the conditional update failed
|
|
return { count: 0 };
|
|
},
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
// Call the service - it should throw because the batch is in an unexpected state
|
|
await expect(
|
|
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
})
|
|
).rejects.toThrow(/unexpected state/);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=false when item count does not match",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// Create a batch expecting 3 items
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 3,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
// Initialize the batch in Redis
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 3,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Only enqueue 2 items (1 short of expected)
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
// Should return sealed=false because item count doesn't match
|
|
expect(result.sealed).toBe(false);
|
|
expect(result.enqueuedCount).toBe(2);
|
|
expect(result.expectedCount).toBe(3);
|
|
|
|
// Verify the batch is NOT sealed in the database
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.sealed).toBe(false);
|
|
expect(updatedBatch?.status).toBe("PENDING");
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when seal-failed race produces sealed=true + PENDING (post-callback all-created)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Simulate the race where a concurrent path seals the batch (sealed=true,
|
|
// PROCESSING), then the V2 batchCompletionCallback fires with all runs
|
|
// created successfully and resets status to PENDING (sealed stays true).
|
|
// Our seal updateMany then fails the conditional (sealed=false no longer
|
|
// matches), and the re-query sees sealed=true + PENDING — a perfectly
|
|
// valid post-callback state that the SDK retry should treat as success.
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: prisma.batchTaskRun.findFirst.bind(prisma.batchTaskRun),
|
|
updateMany: async () => {
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
sealed: true,
|
|
sealedAt: new Date(),
|
|
// Intentionally leave status as PENDING — that's exactly what
|
|
// the V2 batchCompletionCallback does after all runs are
|
|
// created (status PROCESSING → PENDING).
|
|
},
|
|
});
|
|
return { count: 0 };
|
|
},
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
|
|
expect(updatedBatch?.sealed).toBe(true);
|
|
expect(updatedBatch?.status).toBe("PENDING");
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should throw when count-mismatch race produces sealed=true + ABORTED (no TaskRuns created)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 3,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 3,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Only enqueue 2 items so the post-loop count check trips into the
|
|
// mismatch handler. The race we're simulating: between our pre-loop
|
|
// findFirst and the count-mismatch re-query, a concurrent path sealed
|
|
// the batch, runs were attempted, every run-creation failed AND the
|
|
// pre-failed-TaskRun fallback also failed → callback sets ABORTED.
|
|
// The customer has zero TaskRun records to monitor, so the retry must
|
|
// throw rather than silently succeed.
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 0, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item1" }),
|
|
payloadType: "application/json",
|
|
});
|
|
await engine.enqueueBatchItem(batch.id, authenticatedEnvironment.id, 1, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ data: "item2" }),
|
|
payloadType: "application/json",
|
|
});
|
|
|
|
// Override findFirst to flip the batch to sealed=true + ABORTED on the
|
|
// re-query that happens INSIDE the count-mismatch branch. The first
|
|
// findFirst (pre-loop) must still see PENDING + sealed=false so we
|
|
// pass through and reach the count-mismatch branch.
|
|
let findFirstCallCount = 0;
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: async (args: Parameters<typeof prisma.batchTaskRun.findFirst>[0]) => {
|
|
findFirstCallCount++;
|
|
if (findFirstCallCount >= 2) {
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
sealed: true,
|
|
sealedAt: new Date(),
|
|
status: "ABORTED",
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
return prisma.batchTaskRun.findFirst.call(prisma.batchTaskRun, args);
|
|
},
|
|
updateMany: prisma.batchTaskRun.updateMany.bind(prisma.batchTaskRun),
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
// The batch find + seal updateMany now route through the engine's run-store
|
|
// (route-by-batch-id under the run-ops split), so the racing client must back the
|
|
// store the service reads through.
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
await expect(
|
|
service.call(authenticatedEnvironment, batch.friendlyId, itemsToAsyncIterable([]), {
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
})
|
|
).rejects.toThrow(ServiceValidationError);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true when batch is sealed=false + PENDING + processingCompletedAt set (pre-loop post-callback)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// The V2 batchCompletionCallback set processingCompletedAt without
|
|
// touching sealed (sealed gets set by streamBatchItems, not the callback).
|
|
// Status stays PENDING because every run was created successfully (the
|
|
// callback's "all created, waiting for completion" branch). A Phase 2
|
|
// retry arriving in this state must treat it as success — every item
|
|
// has a TaskRun record for the customer to monitor.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 4,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
processingCompletedAt: new Date(),
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
expect(result.itemsAccepted).toBe(0);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"should return sealed=true on count-mismatch when callback fired before our getEnqueuedCount (cleanup race — customer scenario)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = new RunEngine({
|
|
prisma,
|
|
worker: {
|
|
redis: redisOptions,
|
|
workers: 1,
|
|
tasksPerWorker: 10,
|
|
pollIntervalMs: 100,
|
|
disabled: true,
|
|
},
|
|
queue: {
|
|
redis: redisOptions,
|
|
},
|
|
runLock: {
|
|
redis: redisOptions,
|
|
},
|
|
machines: {
|
|
defaultMachine: "small-1x",
|
|
machines: {
|
|
"small-1x": {
|
|
name: "small-1x" as const,
|
|
cpu: 0.5,
|
|
memory: 0.5,
|
|
centsPerMs: 0.0001,
|
|
},
|
|
},
|
|
baseCostInCents: 0.0005,
|
|
},
|
|
batchQueue: {
|
|
redis: redisOptions,
|
|
},
|
|
tracer: trace.getTracer("test", "0.0.0"),
|
|
});
|
|
|
|
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
// The customer's exact case: 4-item batch, BatchQueue rushes through
|
|
// all items before our service finishes its loop, callback fires
|
|
// (setting processingCompletedAt; status stays PENDING because all
|
|
// runs created cleanly), cleanup deletes Redis state. Our service
|
|
// hits the count-mismatch branch because getBatchEnqueuedCount returns
|
|
// 0 (cleaned). The re-query sees sealed=false + PENDING but
|
|
// processingCompletedAt is set — the work is done.
|
|
const batch = await createBatch(prisma, authenticatedEnvironment.id, {
|
|
runCount: 4,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: authenticatedEnvironment.id,
|
|
environmentType: authenticatedEnvironment.type,
|
|
organizationId: authenticatedEnvironment.organizationId,
|
|
projectId: authenticatedEnvironment.projectId,
|
|
runCount: 4,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// The pre-loop validate-find must see "initial" state (so it passes through to the
|
|
// loop), and the count-mismatch re-query must see "post-callback" state. Use a findFirst
|
|
// counter to flip the DB between those two reads, matching production timing where the
|
|
// callback fires while our loop runs. Both reads route through the engine's run-store
|
|
// (route-by-batch-id under the split), so the racing client backs the store.
|
|
let findFirstCallCount = 0;
|
|
const racingPrisma = {
|
|
...prisma,
|
|
batchTaskRun: {
|
|
...prisma.batchTaskRun,
|
|
findFirst: async (args: Parameters<typeof prisma.batchTaskRun.findFirst>[0]) => {
|
|
findFirstCallCount++;
|
|
if (findFirstCallCount === 2) {
|
|
await prisma.batchTaskRun.update({
|
|
where: { id: batch.id },
|
|
data: {
|
|
processingCompletedAt: new Date(),
|
|
successfulRunCount: 4,
|
|
},
|
|
});
|
|
}
|
|
return prisma.batchTaskRun.findFirst.call(prisma.batchTaskRun, args);
|
|
},
|
|
updateMany: prisma.batchTaskRun.updateMany.bind(prisma.batchTaskRun),
|
|
findUnique: prisma.batchTaskRun.findUnique.bind(prisma.batchTaskRun),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
|
|
engine.runStore = new PostgresRunStore({
|
|
prisma: racingPrisma,
|
|
readOnlyPrisma: racingPrisma,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma: racingPrisma,
|
|
engine,
|
|
});
|
|
|
|
const result = await service.call(
|
|
authenticatedEnvironment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable([]),
|
|
{
|
|
maxItemBytes: 1024 * 1024,
|
|
concurrency: 1,
|
|
}
|
|
);
|
|
|
|
// The retry must be treated as success — every item's TaskRun was
|
|
// created by the original Phase 2 call. Returning sealed:false here
|
|
// (the previous behavior) made the SDK retry the stream against a
|
|
// cleaned-up batch, which then 5xx'd, exhausted SDK retries, and
|
|
// surfaced as BatchTriggerError despite all runs succeeding.
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.id).toBe(batch.friendlyId);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"ingests a large batch with bounded concurrency and seals it",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = buildEngine(prisma, redisOptions);
|
|
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const runCount = 100;
|
|
const batch = await createBatch(prisma, environment.id, {
|
|
runCount,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({ prisma, engine });
|
|
|
|
const result = await service.call(
|
|
environment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable(makeItems(runCount)),
|
|
{ maxItemBytes: 1024 * 1024, concurrency: 10 }
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.itemsAccepted).toBe(runCount);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
|
|
// Every item was enqueued exactly once despite concurrent ingestion.
|
|
const enqueuedCount = await engine.getBatchEnqueuedCount(batch.id);
|
|
expect(enqueuedCount).toBe(runCount);
|
|
|
|
const updatedBatch = await prisma.batchTaskRun.findUnique({ where: { id: batch.id } });
|
|
expect(updatedBatch?.sealed).toBe(true);
|
|
expect(updatedBatch?.status).toBe("PROCESSING");
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"bounds in-flight item processing to the configured concurrency",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = buildEngine(prisma, redisOptions);
|
|
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const runCount = 40;
|
|
const concurrency = 5;
|
|
const batch = await createBatch(prisma, environment.id, {
|
|
runCount,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// A real payload processor that holds each slot briefly so we can observe
|
|
// how many items are processed simultaneously. This guards against an
|
|
// accidental regression that buffers the whole stream / ignores the cap.
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
class TrackingPayloadProcessor extends BatchPayloadProcessor {
|
|
async process(payload: unknown, payloadType: string): Promise<BatchPayloadProcessResult> {
|
|
inFlight++;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
try {
|
|
await sleep(20);
|
|
return { payload, payloadType, wasOffloaded: false, size: 0 };
|
|
} finally {
|
|
inFlight--;
|
|
}
|
|
}
|
|
}
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
payloadProcessor: new TrackingPayloadProcessor(),
|
|
});
|
|
|
|
const result = await service.call(
|
|
environment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable(makeItems(runCount)),
|
|
{ maxItemBytes: 1024 * 1024, concurrency }
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.itemsAccepted).toBe(runCount);
|
|
// Actually ran concurrently, but never exceeded the configured cap.
|
|
expect(maxInFlight).toBeGreaterThan(1);
|
|
expect(maxInFlight).toBeLessThanOrEqual(concurrency);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"perf: higher ingest concurrency processes a batch proportionally faster",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = buildEngine(prisma, redisOptions);
|
|
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const runCount = 150;
|
|
|
|
// A payload processor that holds each slot for a fixed duration, modelling
|
|
// the per-item object-store offload. perItemLatencyMs=0 models a small
|
|
// payload that never offloads, leaving the real per-item enqueueBatchItem
|
|
// (a Redis round-trip) as the only serialized cost. Local MinIO/Redis
|
|
// latency is sub-millisecond and too noisy to compare directly, so the
|
|
// offload case uses a fixed hold instead.
|
|
class FixedLatencyPayloadProcessor extends BatchPayloadProcessor {
|
|
constructor(private readonly perItemLatencyMs: number) {
|
|
super();
|
|
}
|
|
async process(payload: unknown, payloadType: string): Promise<BatchPayloadProcessResult> {
|
|
if (this.perItemLatencyMs > 0) {
|
|
await sleep(this.perItemLatencyMs);
|
|
}
|
|
return { payload, payloadType, wasOffloaded: this.perItemLatencyMs > 0, size: 0 };
|
|
}
|
|
}
|
|
|
|
async function timeIngest(concurrency: number, perItemLatencyMs: number): Promise<number> {
|
|
// Each run needs its own batch: sealing mutates state and a re-stream
|
|
// of the same batch would dedup every item.
|
|
const batch = await createBatch(prisma, environment.id, {
|
|
runCount,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({
|
|
prisma,
|
|
engine,
|
|
payloadProcessor: new FixedLatencyPayloadProcessor(perItemLatencyMs),
|
|
});
|
|
|
|
const start = performance.now();
|
|
const result = await service.call(
|
|
environment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable(makeItems(runCount)),
|
|
{ maxItemBytes: 1024 * 1024, concurrency }
|
|
);
|
|
const elapsedMs = performance.now() - start;
|
|
|
|
// Correctness holds at every concurrency: all items accepted and sealed.
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.itemsAccepted).toBe(runCount);
|
|
expect(result.itemsDeduplicated).toBe(0);
|
|
expect(await engine.getBatchEnqueuedCount(batch.id)).toBe(runCount);
|
|
|
|
return elapsedMs;
|
|
}
|
|
|
|
// Scenario A: large payloads, where each item offloads to object storage.
|
|
const offloadLatencyMs = 10;
|
|
const offloadSeqMs = await timeIngest(1, offloadLatencyMs);
|
|
const offload10Ms = await timeIngest(10, offloadLatencyMs);
|
|
const offload50Ms = await timeIngest(50, offloadLatencyMs);
|
|
|
|
// Scenario B: small payloads (no offload). The only per-item cost is the
|
|
// real Redis enqueue, so this is the floor case that proves the speedup
|
|
// applies to all batch ingest, not just large-payload batches.
|
|
const enqueueSeqMs = await timeIngest(1, 0);
|
|
const enqueue10Ms = await timeIngest(10, 0);
|
|
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`\n[streamBatchItems perf] runCount=${runCount}\n` +
|
|
` large payloads (${offloadLatencyMs}ms/item offload):\n` +
|
|
` concurrency=1 ${offloadSeqMs.toFixed(0)}ms\n` +
|
|
` concurrency=10 ${offload10Ms.toFixed(0)}ms (${(offloadSeqMs / offload10Ms).toFixed(
|
|
1
|
|
)}x faster)\n` +
|
|
` concurrency=50 ${offload50Ms.toFixed(0)}ms (${(offloadSeqMs / offload50Ms).toFixed(
|
|
1
|
|
)}x faster)\n` +
|
|
` small payloads (Redis enqueue only, no offload):\n` +
|
|
` concurrency=1 ${enqueueSeqMs.toFixed(0)}ms\n` +
|
|
` concurrency=10 ${enqueue10Ms.toFixed(0)}ms (${(enqueueSeqMs / enqueue10Ms).toFixed(
|
|
1
|
|
)}x faster)\n`
|
|
);
|
|
|
|
// Sequential floor: N items each held for offloadLatencyMs cannot finish
|
|
// faster than N x latency. Parallel ingest must beat that floor decisively.
|
|
const sequentialFloorMs = runCount * offloadLatencyMs;
|
|
expect(offloadSeqMs).toBeGreaterThan(sequentialFloorMs * 0.8);
|
|
|
|
// 10x concurrency on a latency-bound workload should be well over 2x faster.
|
|
expect(offload10Ms).toBeLessThan(offloadSeqMs / 2);
|
|
// More concurrency keeps helping (or at least never regresses).
|
|
expect(offload50Ms).toBeLessThanOrEqual(offload10Ms * 1.2);
|
|
|
|
// Even with no offload, overlapping the per-item Redis enqueue is strictly
|
|
// faster than doing them one at a time.
|
|
expect(enqueue10Ms).toBeLessThan(enqueueSeqMs);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"deduplicates already-enqueued items during concurrent ingest (Phase 2 retry)",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = buildEngine(prisma, redisOptions);
|
|
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const runCount = 20;
|
|
const batch = await createBatch(prisma, environment.id, {
|
|
runCount,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
// Pre-enqueue the first half (simulating a partially-completed prior stream
|
|
// that failed before sealing). The retry re-streams everything.
|
|
for (let index = 0; index < 10; index++) {
|
|
await engine.enqueueBatchItem(batch.id, environment.id, index, {
|
|
task: "test-task",
|
|
payload: JSON.stringify({ i: index }),
|
|
payloadType: "application/json",
|
|
});
|
|
}
|
|
|
|
const service = new StreamBatchItemsService({ prisma, engine });
|
|
|
|
const result = await service.call(
|
|
environment,
|
|
batch.friendlyId,
|
|
itemsToAsyncIterable(makeItems(runCount)),
|
|
{ maxItemBytes: 1024 * 1024, concurrency: 8 }
|
|
);
|
|
|
|
expect(result.sealed).toBe(true);
|
|
expect(result.itemsDeduplicated).toBe(10);
|
|
expect(result.itemsAccepted).toBe(10);
|
|
|
|
const enqueuedCount = await engine.getBatchEnqueuedCount(batch.id);
|
|
expect(enqueuedCount).toBe(runCount);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"rejects an oversized item whose index exceeds runCount",
|
|
async ({ prisma, redisOptions }) => {
|
|
const engine = buildEngine(prisma, redisOptions);
|
|
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
|
|
|
const batch = await createBatch(prisma, environment.id, {
|
|
runCount: 2,
|
|
status: "PENDING",
|
|
sealed: false,
|
|
});
|
|
|
|
await engine.initializeBatch({
|
|
batchId: batch.id,
|
|
friendlyId: batch.friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount: 2,
|
|
processingConcurrency: 10,
|
|
});
|
|
|
|
const service = new StreamBatchItemsService({ prisma, engine });
|
|
|
|
// An oversized marker must hit the same out-of-range guard as a normal
|
|
// item rather than slipping through to create a stray pre-failed run.
|
|
async function* oversizedOutOfRange() {
|
|
yield {
|
|
__batchItemError: "OVERSIZED",
|
|
index: 5,
|
|
task: "test-task",
|
|
actualSize: 9999,
|
|
maxSize: 1000,
|
|
};
|
|
}
|
|
|
|
await expect(
|
|
service.call(environment, batch.friendlyId, oversizedOutOfRange(), {
|
|
maxItemBytes: 1024,
|
|
concurrency: 4,
|
|
})
|
|
).rejects.toThrow(ServiceValidationError);
|
|
|
|
await engine.quit();
|
|
}
|
|
);
|
|
});
|
|
|
|
describe("createNdjsonParserStream", () => {
|
|
async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> {
|
|
const results: T[] = [];
|
|
for await (const item of streamToAsyncIterable(stream)) {
|
|
results.push(item);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function chunksToStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
|
let index = 0;
|
|
return new ReadableStream({
|
|
pull(controller) {
|
|
if (index < chunks.length) {
|
|
controller.enqueue(chunks[index++]);
|
|
} else {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
it("should parse basic NDJSON correctly", async () => {
|
|
const ndjson = '{"name":"alice"}\n{"name":"bob"}\n{"name":"charlie"}\n';
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]);
|
|
});
|
|
|
|
it("should handle NDJSON without trailing newline", async () => {
|
|
const ndjson = '{"id":1}\n{"id":2}';
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ id: 1 }, { id: 2 }]);
|
|
});
|
|
|
|
it("should handle escaped newlines in JSON string values", async () => {
|
|
// JSON.stringify escapes newlines as \n (two chars: backslash + n),
|
|
// so they don't break NDJSON line boundaries. This is the normal case
|
|
// when the SDK serializes payloads containing newlines.
|
|
const item1 = JSON.stringify({ payload: "line1\nline2\nline3" });
|
|
const item2 = JSON.stringify({ payload: "no newlines" });
|
|
const ndjson = item1 + "\n" + item2 + "\n";
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ payload: "line1\nline2\nline3" }, { payload: "no newlines" }]);
|
|
});
|
|
|
|
it("should skip empty lines", async () => {
|
|
const ndjson = '{"a":1}\n\n{"b":2}\n \n{"c":3}\n';
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
|
|
});
|
|
|
|
it("should handle chunks split mid-line", async () => {
|
|
// Split '{"split":"value"}\n' across multiple chunks
|
|
const encoder = new TextEncoder();
|
|
const chunks = [encoder.encode('{"spl'), encoder.encode('it":"va'), encoder.encode('lue"}\n')];
|
|
const stream = chunksToStream(chunks);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ split: "value" }]);
|
|
});
|
|
|
|
it("should handle multibyte UTF-8 characters split across chunks", async () => {
|
|
// Test with emoji and other multibyte characters
|
|
// The emoji "😀" is 4 bytes: 0xF0 0x9F 0x98 0x80
|
|
const json = '{"emoji":"😀"}\n';
|
|
const fullBytes = new TextEncoder().encode(json);
|
|
|
|
// Split in the middle of the emoji (between byte 2 and 3 of the 4-byte sequence)
|
|
// Find where the emoji starts
|
|
const emojiStart = fullBytes.indexOf(0xf0);
|
|
expect(emojiStart).toBeGreaterThan(0);
|
|
|
|
// Split after first 2 bytes of the emoji
|
|
const chunk1 = fullBytes.slice(0, emojiStart + 2);
|
|
const chunk2 = fullBytes.slice(emojiStart + 2);
|
|
|
|
const stream = chunksToStream([chunk1, chunk2]);
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ emoji: "😀" }]);
|
|
});
|
|
|
|
it("should handle multiple multibyte characters across chunks", async () => {
|
|
// Japanese text: "こんにちは" (each hiragana is 3 bytes in UTF-8)
|
|
const json = '{"greeting":"こんにちは"}\n';
|
|
const fullBytes = new TextEncoder().encode(json);
|
|
|
|
// Split into many small chunks to stress test UTF-8 handling
|
|
const chunkSize = 3; // Deliberately misaligned with UTF-8 boundaries
|
|
const chunks: Uint8Array[] = [];
|
|
for (let i = 0; i < fullBytes.length; i += chunkSize) {
|
|
chunks.push(fullBytes.slice(i, i + chunkSize));
|
|
}
|
|
|
|
const stream = chunksToStream(chunks);
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ greeting: "こんにちは" }]);
|
|
});
|
|
|
|
it("should emit OversizedItemMarker for lines exceeding maxItemBytes", async () => {
|
|
const maxBytes = 50;
|
|
// Create a line that exceeds the limit with index and task fields
|
|
const largeJson = JSON.stringify({ index: 3, task: "my-task", data: "x".repeat(100) }) + "\n";
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(largeJson)]);
|
|
|
|
const parser = createNdjsonParserStream(maxBytes);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toHaveLength(1);
|
|
const marker = results[0] as OversizedItemMarker;
|
|
expect(marker.__batchItemError).toBe("OVERSIZED");
|
|
expect(marker.index).toBe(3);
|
|
expect(marker.task).toBe("my-task");
|
|
expect(marker.maxSize).toBe(maxBytes);
|
|
expect(marker.actualSize).toBeGreaterThan(maxBytes);
|
|
});
|
|
|
|
it("should emit OversizedItemMarker for unbounded accumulation without newlines", async () => {
|
|
const maxBytes = 50;
|
|
// Send data without any newlines that exceeds the buffer limit
|
|
const encoder = new TextEncoder();
|
|
const chunks = [
|
|
encoder.encode('{"index":7,"task":"big-task","start":"'),
|
|
encoder.encode("x".repeat(60)), // This will push buffer over 50 bytes
|
|
];
|
|
const stream = chunksToStream(chunks);
|
|
|
|
const parser = createNdjsonParserStream(maxBytes);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toHaveLength(1);
|
|
const marker = results[0] as OversizedItemMarker;
|
|
expect(marker.__batchItemError).toBe("OVERSIZED");
|
|
expect(marker.index).toBe(7);
|
|
expect(marker.task).toBe("big-task");
|
|
expect(marker.maxSize).toBe(maxBytes);
|
|
});
|
|
|
|
it("should skip remaining bytes of oversized line arriving in subsequent chunks", async () => {
|
|
const maxBytes = 50;
|
|
const encoder = new TextEncoder();
|
|
// Simulate a normal item, then an oversized item split across many chunks,
|
|
// then another normal item after the newline.
|
|
// The oversized line is: {"index":1,"task":"t","data":"xxxx...120 x's...xxxx"}\n
|
|
const normalItem1 = '{"index":0,"task":"t","x":1}\n';
|
|
const oversizedStart = '{"index":1,"task":"t","data":"';
|
|
const oversizedMiddle = "x".repeat(120); // way over 50 bytes
|
|
const oversizedEnd = '"}\n';
|
|
const normalItem2 = '{"index":2,"task":"t","x":2}\n';
|
|
|
|
// Send as separate chunks to trigger Case 2 (no newline, buffer > limit)
|
|
const chunks = [
|
|
encoder.encode(normalItem1 + oversizedStart),
|
|
encoder.encode(oversizedMiddle.slice(0, 60)),
|
|
encoder.encode(oversizedMiddle.slice(60)),
|
|
encoder.encode(oversizedEnd + normalItem2),
|
|
];
|
|
const stream = chunksToStream(chunks);
|
|
|
|
const parser = createNdjsonParserStream(maxBytes);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
// Should get: normal item 1, oversized marker, normal item 2
|
|
expect(results).toHaveLength(3);
|
|
expect(results[0]).toEqual({ index: 0, task: "t", x: 1 });
|
|
expect((results[1] as OversizedItemMarker).__batchItemError).toBe("OVERSIZED");
|
|
expect((results[1] as OversizedItemMarker).index).toBe(1);
|
|
expect(results[2]).toEqual({ index: 2, task: "t", x: 2 });
|
|
});
|
|
|
|
it("stamps an oversized marker's index by emit position when it can't be extracted", async () => {
|
|
// Concurrency-safety: when the `index` field can't be recovered from the
|
|
// (truncated) raw bytes, the parser must back-fill it from the emit
|
|
// position rather than leaving it for the consumer to infer sequentially.
|
|
const maxBytes = 50;
|
|
const encoder = new TextEncoder();
|
|
// item 0: small + valid
|
|
const item0 = '{"index":0,"task":"t","x":1}\n';
|
|
// item 1: oversized AND missing an `index` field → extractIndexAndTask returns -1
|
|
const item1 = `{"task":"big","data":"${"x".repeat(100)}"}\n`;
|
|
// item 2: small + valid, must keep its own index
|
|
const item2 = '{"index":2,"task":"t","x":2}\n';
|
|
const stream = chunksToStream([encoder.encode(item0 + item1 + item2)]);
|
|
|
|
const parser = createNdjsonParserStream(maxBytes);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toHaveLength(3);
|
|
expect(results[0]).toEqual({ index: 0, task: "t", x: 1 });
|
|
const marker = results[1] as OversizedItemMarker;
|
|
expect(marker.__batchItemError).toBe("OVERSIZED");
|
|
expect(marker.index).toBe(1); // emit-position fallback, not -1
|
|
expect(marker.task).toBe("big");
|
|
expect(results[2]).toEqual({ index: 2, task: "t", x: 2 });
|
|
});
|
|
|
|
it("should check byte size before decoding to prevent OOM", async () => {
|
|
// This test verifies that size is checked on raw bytes, not decoded string length
|
|
// Unicode characters like "🎉" are 4 bytes but 2 UTF-16 code units (string length 2)
|
|
const maxBytes = 30;
|
|
|
|
// Create a line with emojis - each emoji is 4 bytes
|
|
// {"e":"🎉🎉🎉🎉🎉"} = 5 + 20 (5 emojis * 4 bytes) + 2 = 27 bytes - should pass
|
|
const smallJson = '{"e":"🎉🎉🎉🎉🎉"}\n';
|
|
const smallBytes = new TextEncoder().encode(smallJson);
|
|
expect(smallBytes.length).toBeLessThan(maxBytes);
|
|
|
|
// {"e":"🎉🎉🎉🎉🎉🎉🎉"} = 7 emojis * 4 bytes + overhead = 35 bytes - should fail
|
|
const largeJson = '{"e":"🎉🎉🎉🎉🎉🎉🎉"}\n';
|
|
const largeBytes = new TextEncoder().encode(largeJson);
|
|
expect(largeBytes.length).toBeGreaterThan(maxBytes);
|
|
|
|
// Small one should succeed
|
|
const stream1 = chunksToStream([smallBytes]);
|
|
const parser1 = createNdjsonParserStream(maxBytes);
|
|
const results1 = await collectStream(stream1.pipeThrough(parser1));
|
|
expect(results1).toHaveLength(1);
|
|
|
|
// Large one should emit an OversizedItemMarker
|
|
const stream2 = chunksToStream([largeBytes]);
|
|
const parser2 = createNdjsonParserStream(maxBytes);
|
|
const results2 = await collectStream(stream2.pipeThrough(parser2));
|
|
expect(results2).toHaveLength(1);
|
|
expect((results2[0] as OversizedItemMarker).__batchItemError).toBe("OVERSIZED");
|
|
});
|
|
|
|
it("should handle final line in flush without trailing newline", async () => {
|
|
const ndjson = '{"first":1}\n{"second":2}'; // No trailing newline
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ first: 1 }, { second: 2 }]);
|
|
});
|
|
|
|
it("should reject invalid JSON", async () => {
|
|
const ndjson = '{"valid":true}\n{invalid json}\n';
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
|
|
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(
|
|
/Invalid JSON at line 2/
|
|
);
|
|
});
|
|
|
|
it("should reject invalid UTF-8 sequences", async () => {
|
|
// Invalid UTF-8: 0xFF is never valid in UTF-8
|
|
const invalidBytes = new Uint8Array([
|
|
0x7b,
|
|
0x22,
|
|
0x78,
|
|
0x22,
|
|
0x3a,
|
|
0xff,
|
|
0x7d,
|
|
0x0a, // {"x":�}\n with invalid byte
|
|
]);
|
|
const stream = chunksToStream([invalidBytes]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
|
|
await expect(collectStream(stream.pipeThrough(parser))).rejects.toThrow(/Invalid UTF-8/);
|
|
});
|
|
|
|
it("should handle many small chunks efficiently", async () => {
|
|
// Simulate streaming byte-by-byte
|
|
const json = '{"test":"value"}\n';
|
|
const bytes = new TextEncoder().encode(json);
|
|
const chunks = Array.from(bytes).map((b) => new Uint8Array([b]));
|
|
|
|
const stream = chunksToStream(chunks);
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ test: "value" }]);
|
|
});
|
|
|
|
it("should handle multiple lines per chunk", async () => {
|
|
const ndjson = '{"a":1}\n{"b":2}\n{"c":3}\n{"d":4}\n{"e":5}\n';
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(ndjson)]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }]);
|
|
});
|
|
|
|
it("should handle empty stream", async () => {
|
|
const stream = chunksToStream([]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it("should pass normal items and emit markers for oversized items in the same stream", async () => {
|
|
const maxBytes = 50;
|
|
const encoder = new TextEncoder();
|
|
// Normal item, then oversized item, then another normal item
|
|
const normalItem1 = '{"index":0,"task":"t","x":1}\n';
|
|
const oversizedItem = JSON.stringify({ index: 1, task: "t", data: "x".repeat(100) }) + "\n";
|
|
const normalItem2 = '{"index":2,"task":"t","x":2}\n';
|
|
const stream = chunksToStream([encoder.encode(normalItem1 + oversizedItem + normalItem2)]);
|
|
|
|
const parser = createNdjsonParserStream(maxBytes);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toHaveLength(3);
|
|
// First: normal parsed object
|
|
expect(results[0]).toEqual({ index: 0, task: "t", x: 1 });
|
|
// Second: oversized marker
|
|
expect((results[1] as OversizedItemMarker).__batchItemError).toBe("OVERSIZED");
|
|
expect((results[1] as OversizedItemMarker).index).toBe(1);
|
|
// Third: normal parsed object
|
|
expect(results[2]).toEqual({ index: 2, task: "t", x: 2 });
|
|
});
|
|
|
|
it("should handle stream with only whitespace", async () => {
|
|
const encoder = new TextEncoder();
|
|
const stream = chunksToStream([encoder.encode(" \n\n \n")]);
|
|
|
|
const parser = createNdjsonParserStream(1024);
|
|
const results = await collectStream(stream.pipeThrough(parser));
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("extractIndexAndTask", () => {
|
|
const encoder = new TextEncoder();
|
|
|
|
it("should extract index and task from JSON bytes", () => {
|
|
const bytes = encoder.encode('{"index":42,"task":"my-task","data":"x"}');
|
|
const result = extractIndexAndTask(bytes);
|
|
expect(result.index).toBe(42);
|
|
expect(result.task).toBe("my-task");
|
|
});
|
|
|
|
it("should return defaults for empty or malformed bytes", () => {
|
|
const result = extractIndexAndTask(new Uint8Array(0));
|
|
expect(result.index).toBe(-1);
|
|
expect(result.task).toBe("unknown");
|
|
});
|
|
|
|
it("should handle keys in any order", () => {
|
|
const bytes = encoder.encode('{"task":"other-task","data":"y","index":99}');
|
|
const result = extractIndexAndTask(bytes);
|
|
expect(result.index).toBe(99);
|
|
expect(result.task).toBe("other-task");
|
|
});
|
|
|
|
it("should not match nested keys", () => {
|
|
const bytes = encoder.encode(
|
|
'{"nested":{"index":999,"task":"inner"},"index":5,"task":"outer"}'
|
|
);
|
|
const result = extractIndexAndTask(bytes);
|
|
expect(result.index).toBe(5);
|
|
expect(result.task).toBe("outer");
|
|
});
|
|
});
|