Files
triggerdotdev--trigger.dev/apps/webapp/test/runEngineBatchTriggerStoreRouting.test.ts
Daniel Sutton 84f3e1b39c feat(run-ops): webapp write path — trigger/batch minting, idempotency routing, run lifecycle (#4118)
## What

Routes the webapp write path through the run-ops split seam:
trigger/batch minting, idempotency-key resolution, and the run-lifecycle
services now determine residency and dispatch writes to the correct
store.

- **Trigger & batch** (`runEngine/services/triggerTask.server.ts`,
`batchTrigger.server.ts`, `createBatch.server.ts`,
`streamBatchItems.server.ts`, `v3/services/batchTriggerV3.server.ts`):
mint ids with the run-ops-aware minting and route creation/streaming
through the store; batch children inherit the parent's residency.
- **Idempotency** (`runEngine/concerns/idempotencyKeys.server.ts` + new
`idempotencyResidency.server.ts`): idempotency-key lookup/dedup is
residency-aware so a keyed retrigger resolves against the store that
owns the original run.
- **Run lifecycle services** (`createCheckpoint`,
`createTaskRunAttempt`, `enqueueDelayedRun`, `expireEnqueuedRun`,
`finalizeTaskRun`, `resumeBatchRun`, `cancelDevSessionRuns`,
`executeTasksWaitingForDeploy`, `triggerFailedTask`): resolve their
target run through the store rather than a fixed client.
- **Reads that fan out from writes** (`runsRepository` +
`clickhouseRunsRepository`, `BulkActionV2` + batch read-through,
realtime `sessions`/`runReader`, alerts
`deliverAlert`/`performTaskRunAlerts`): route through the read-through
resolver.
- `9535ae63d` — resolves the parent run through an injectable run store
in `TriggerFailedTaskService`.
- `bf8f7c881` — drops the "known-migrated" concept from write-path and
read repos; residency is id-shape only.
- `515b897ea` — self-defaults `resolveWaitpointThroughReadThrough` to
the safe run-ops clients.

## Why

PR6 of the run-ops split stack. This is the write-path counterpart to
the read foundation in the previous PRs: with it in place, both reads
and writes route through the seam. Additive when the split is disabled
(id-shape resolution collapses to the control-plane client);
behavior-changing on the minting, idempotency, and lifecycle paths when
enabled.

## Tests

Large new/expanded vitest suite under `apps/webapp/test/` and colocated
service tests: trigger-task and batch-trigger store routing, residency
inheritance, idempotency dedup residency + legacy-authority, bulk-action
read routing, cancel-dev-session routing, alerts store routing,
runs-repository read-through, realtime session/run-reader read-through
and stream-registration routing, and the waitpoint read-through default.
Testcontainers-backed; no mocks.

## Notes

Draft, **stacked on #4117** (`runops/pr05-webapp-foundation`). Review
that first; this diff is against it.

Server-change / changeset note to be added at stack-assembly time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:52:08 +01:00

173 lines
6.1 KiB
TypeScript

import { describe, expect, vi } from "vitest";
// Redirect the module-level db client to the per-test container prisma so the worker-path
// env resolution (`findEnvironmentById`/`controlPlaneResolver`, which read `~/db.server`)
// hits the real container DB. The DB itself is never mocked — only the module binding is
// pointed at the container client created by the fixture.
const dbHolder = vi.hoisted(() => ({ prisma: undefined as any }));
vi.mock("~/db.server", () => ({
get prisma() {
return dbHolder.prisma;
},
get $replica() {
return dbHolder.prisma;
},
}));
import { RunEngine } from "@internal/run-engine";
import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests";
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
import { containerTestWithIsolatedRedisNoClickhouse as containerTest } from "@internal/testcontainers";
import { trace } from "@opentelemetry/api";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import type { PrismaClient } from "@trigger.dev/database";
import { RunEngineBatchTriggerService } from "../app/runEngine/services/batchTrigger.server";
vi.setConfig({ testTimeout: 120_000 });
function buildEngine(prisma: PrismaClient, redisOptions: any, store?: RoutingRunStore) {
return new RunEngine({
prisma,
...(store ? { store } : {}),
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"),
});
}
function batchCreateData(params: {
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
runCount: number;
payload: string;
}) {
return {
id: params.id,
friendlyId: params.friendlyId,
runtimeEnvironmentId: params.runtimeEnvironmentId,
runCount: params.runCount,
runIds: [] as string[],
payload: params.payload,
payloadType: "application/json",
options: {},
batchVersion: "runengine:v1",
};
}
describe("RunEngineBatchTriggerService store routing", () => {
// The service issues BatchTaskRun create/find/update through `this._engine.runStore`.
// With an injected RoutingRunStore whose NEW slot is a PostgresRunStore, those calls
// land on the run-ops store (born on NEW), not on a separate `this._prisma` path.
containerTest(
"create/find/update route through the injected run-ops store",
async ({ prisma, redisOptions }) => {
dbHolder.prisma = prisma;
const runStore = new RoutingRunStore({
new: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }),
legacy: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }),
});
const engine = buildEngine(prisma, redisOptions, runStore);
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const service = new RunEngineBatchTriggerService("sequential", prisma, engine);
// The service holds the injected routing store.
expect(service["_engine"].runStore).toBe(runStore);
// (create) Born on the run-ops store and present in the DB.
const { id, friendlyId } = BatchId.generate();
const created = await service["_engine"].runStore.createBatchTaskRun(
batchCreateData({
id,
friendlyId,
runtimeEnvironmentId: authenticatedEnvironment.id,
runCount: 1,
payload: "[]",
})
);
expect(created.id).toBe(id);
expect(await prisma.batchTaskRun.findUnique({ where: { id } })).not.toBeNull();
// (find + update) Drive the worker entrypoint with an empty payload so no child runs
// are triggered: the path exercises findBatchTaskRunById -> findEnvironmentById ->
// inline-payload parse -> updateBatchTaskRun, all through the store.
await service.processBatchTaskRun({
batchId: id,
processingId: "0",
range: { start: 0, count: 50 },
attemptCount: 0,
strategy: "sequential",
});
// The update routed through the store ran (processingJobsCount incremented by the 0
// processed items; runIds untouched). The row is the one written to the run-ops DB.
const after = await prisma.batchTaskRun.findUnique({ where: { id } });
expect(after).not.toBeNull();
expect(after!.processingJobsCount).toBe(0);
expect(after!.runIds).toEqual([]);
await engine.quit();
}
);
// Single-DB passthrough (self-host collapse): with no `store` injected, the engine
// defaults to a PostgresRunStore over the one client, byte-identical to pre-routing.
containerTest(
"single-DB passthrough uses the default PostgresRunStore",
async ({ prisma, redisOptions }) => {
dbHolder.prisma = prisma;
const engine = buildEngine(prisma, redisOptions);
const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
const service = new RunEngineBatchTriggerService("sequential", prisma, engine);
// The default store is a plain PostgresRunStore (no RoutingRunStore, no second client).
expect(service["_engine"].runStore).toBeInstanceOf(PostgresRunStore);
expect(service["_engine"].runStore).not.toBeInstanceOf(RoutingRunStore);
const { id, friendlyId } = BatchId.generate();
await service["_engine"].runStore.createBatchTaskRun(
batchCreateData({
id,
friendlyId,
runtimeEnvironmentId: authenticatedEnvironment.id,
runCount: 1,
payload: "[]",
})
);
await service.processBatchTaskRun({
batchId: id,
processingId: "0",
range: { start: 0, count: 50 },
attemptCount: 0,
strategy: "sequential",
});
const after = await prisma.batchTaskRun.findUnique({ where: { id } });
expect(after).not.toBeNull();
expect(after!.processingJobsCount).toBe(0);
await engine.quit();
}
);
});