feat(run-ops): read presenters — de-join control-plane relations + read-through hydration (#4122)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Route dashboard and API run/batch/waitpoint presenter reads through the run store so they can be served from a separate backing store without changing call sites.
|
||||
@@ -1,81 +1,219 @@
|
||||
import type { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
$replica,
|
||||
type PrismaClientOrTransaction,
|
||||
type PrismaReplicaClient,
|
||||
prisma,
|
||||
} from "~/db.server";
|
||||
import type { TaskRunWithAttempts } from "~/models/taskRun.server";
|
||||
import { executionResultForTaskRun } from "~/models/taskRun.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
/**
|
||||
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
|
||||
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
|
||||
*/
|
||||
type ApiBatchResultsReadThroughDeps = {
|
||||
splitEnabled?: boolean;
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
isPastRetention?: (runId: string) => boolean;
|
||||
};
|
||||
|
||||
// The TaskRun shape `executionResultForTaskRun` consumes. Shared by both read sites.
|
||||
const memberRunSelect = {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
taskIdentifier: true,
|
||||
attempts: {
|
||||
select: {
|
||||
status: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
error: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Split on: the batch row + its item rows resolve new-run-ops first, then the LEGACY RUN-OPS
|
||||
* READ REPLICA ONLY (never the legacy primary — there is no such handle); each member run is
|
||||
* hydrated independently via readThroughRun keyed on the member runId, so a batch whose members
|
||||
* span migrated + abandoned runs returns the complete reachable set (the batch-spanning-the-line
|
||||
* read; the dangling-reference termination gate is a separate, adjacent unit).
|
||||
*
|
||||
* Split off (single-DB / self-host): one passthrough read for the batch row + a single store
|
||||
* id-set hydrate for the members — no legacy read, no known-migrated probe, no second connection.
|
||||
*/
|
||||
export class ApiBatchResultsPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient: PrismaClientOrTransaction = prisma,
|
||||
replicaClient: PrismaClientOrTransaction = $replica,
|
||||
private readonly readThrough?: ApiBatchResultsReadThroughDeps,
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
// Route through the store so a NEW-resident batch resolves under the run-ops split (the
|
||||
// router probes NEW→LEGACY and drops this client hint) instead of 404ing on a control-plane read.
|
||||
const batchRun = await runStore.findBatchTaskRunByFriendlyId(
|
||||
friendlyId,
|
||||
env.id,
|
||||
{
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
taskRunId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
const splitEnabled = this.readThrough?.splitEnabled ?? false;
|
||||
|
||||
if (!batchRun) {
|
||||
return undefined;
|
||||
if (!splitEnabled) {
|
||||
return this.#callPassthrough(friendlyId, env);
|
||||
}
|
||||
|
||||
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
|
||||
|
||||
if (taskRunIds.length === 0) {
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const taskRuns = await runStore.findRuns(
|
||||
{
|
||||
where: { id: { in: taskRunIds } },
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
taskIdentifier: true,
|
||||
attempts: {
|
||||
select: {
|
||||
status: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
error: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
|
||||
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
|
||||
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: batchRun.items
|
||||
.map((item) => {
|
||||
const run = runMap.get(item.taskRunId);
|
||||
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
};
|
||||
return this.#callSplit(friendlyId, env);
|
||||
});
|
||||
}
|
||||
|
||||
// Passthrough: batch row off the replica, members via the single run store. No legacy read.
|
||||
async #callPassthrough(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
const batchRun = await this._replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
taskRunId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!batchRun) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
|
||||
|
||||
if (taskRunIds.length === 0) {
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const taskRuns = await this.runStore.findRuns(
|
||||
{
|
||||
where: { id: { in: taskRunIds } },
|
||||
select: memberRunSelect,
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
|
||||
const runMap = new Map(taskRuns.map((run) => [run.id, run]));
|
||||
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: batchRun.items
|
||||
.map((item) => {
|
||||
const run = runMap.get(item.taskRunId);
|
||||
return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined;
|
||||
})
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
|
||||
// be cuid or ksuid, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
|
||||
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
|
||||
// hydrate every member run independently via the per-run read-through primitive.
|
||||
async #callSplit(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
// Resolve both handles ONCE so the batch row and its members never read from different DBs.
|
||||
const newClient = (this.readThrough?.newClient ?? this._replica) as PrismaReplicaClient;
|
||||
const legacyReplica = (this.readThrough?.legacyReplica ?? this._replica) as PrismaReplicaClient;
|
||||
|
||||
const readBatch = (client: PrismaClientOrTransaction) =>
|
||||
client.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
taskRunId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let batchRun = await readBatch(newClient);
|
||||
|
||||
// Legacy READ REPLICA probe, only on a new-probe miss; skipped when past retention.
|
||||
if (!batchRun && !this.readThrough?.isPastRetention?.(friendlyId)) {
|
||||
batchRun = await readBatch(legacyReplica);
|
||||
}
|
||||
|
||||
if (!batchRun) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (batchRun.items.length === 0) {
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) =>
|
||||
client.taskRun.findFirst({
|
||||
where: { id: taskRunId },
|
||||
select: memberRunSelect,
|
||||
}) as Promise<TaskRunWithAttempts | null>;
|
||||
|
||||
// Per-member fan-out: each member may live on a different DB, so a single nested include cannot
|
||||
// cross the seam. Promise.all preserves batchRun.items order, unchanged from today.
|
||||
const memberResults = await Promise.all(
|
||||
batchRun.items.map(async (item) => {
|
||||
const result = await readThroughRun<TaskRunWithAttempts>({
|
||||
runId: item.taskRunId,
|
||||
environmentId: env.id,
|
||||
readNew: (client) => readMemberRun(client, item.taskRunId),
|
||||
readLegacy: (replica) => readMemberRun(replica, item.taskRunId),
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
// Pass the SAME resolved handles the batch row used, so the batch row and its members
|
||||
// never resolve against different DBs. (Letting these fall through to readThroughRun's
|
||||
// own module-level defaults would diverge from the batch read's `?? this._replica`.)
|
||||
newClient,
|
||||
legacyReplica,
|
||||
isPastRetention: this.readThrough?.isPastRetention,
|
||||
},
|
||||
});
|
||||
|
||||
// not-found / past-retention members are omitted (matches today's drop-undefined behavior);
|
||||
// the dangling-reference termination gate (separate unit) governs whether that's permitted.
|
||||
if (result.source === "not-found" || result.source === "past-retention") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return executionResultForTaskRun(result.value);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: memberResults.filter(Boolean),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ import {
|
||||
} from "~/v3/mollifier/readFallback.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { tracer } from "~/v3/tracer.server";
|
||||
import { startSpanWithEnv } from "~/v3/tracing.server";
|
||||
|
||||
// Build 'select' object
|
||||
const commonRunSelect = {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
@@ -54,11 +54,7 @@ const commonRunSelect = {
|
||||
scheduleId: true,
|
||||
workerQueue: true,
|
||||
region: true,
|
||||
lockedToVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
lockedToVersionId: true,
|
||||
resumeParentOnCompletion: true,
|
||||
batch: {
|
||||
select: {
|
||||
@@ -75,14 +71,19 @@ type CommonRelatedRun = Prisma.Result<
|
||||
"findFirstOrThrow"
|
||||
>;
|
||||
|
||||
// commonRunSelect carries only the scalar `lockedToVersionId`; `version` is resolved via the
|
||||
// control-plane resolver and folded back on for the run and each related run, since
|
||||
// `createCommonRunStructure` reads `.lockedToVersion?.version` for all of them.
|
||||
type CommonRelatedRunWithVersion = CommonRelatedRun & {
|
||||
lockedToVersion: { version: string } | null;
|
||||
};
|
||||
|
||||
// Full shape returned by findRun() — the commonRunSelect fields plus the
|
||||
// extras the route handler reads. Declared explicitly (not inferred via
|
||||
// ReturnType<typeof findRun>) so findRun can return a synthesised buffered
|
||||
// run without the type becoming self-referential.
|
||||
// Exported so the buffer-synthesis helper below can be unit-tested
|
||||
// against a stable shape without re-deriving it (FoundRun's exact field
|
||||
// list is what the buffered run must match for `call()` not to surprise).
|
||||
export type FoundRun = CommonRelatedRun & {
|
||||
// run without the type becoming self-referential. Exported so the
|
||||
// buffer-synthesis helper below can match this shape under unit test.
|
||||
export type FoundRun = CommonRelatedRunWithVersion & {
|
||||
traceId: string;
|
||||
payload: string;
|
||||
payloadType: string;
|
||||
@@ -93,17 +94,14 @@ export type FoundRun = CommonRelatedRun & {
|
||||
attemptNumber: number | null;
|
||||
engine: "V1" | "V2";
|
||||
taskEventStore: string;
|
||||
parentTaskRun: CommonRelatedRun | null;
|
||||
rootTaskRun: CommonRelatedRun | null;
|
||||
childRuns: CommonRelatedRun[];
|
||||
parentTaskRun: CommonRelatedRunWithVersion | null;
|
||||
rootTaskRun: CommonRelatedRunWithVersion | null;
|
||||
childRuns: CommonRelatedRunWithVersion[];
|
||||
// True when this run was synthesised from the mollifier buffer rather
|
||||
// than read from Postgres. Callers that would otherwise query backing
|
||||
// stores keyed on PG identifiers (e.g. ClickHouse event lookups by
|
||||
// traceId) can short-circuit to an empty response — buffered runs
|
||||
// haven't executed and have no events to fetch. Devin's analysis on
|
||||
// PR #3755 (events endpoint) flagged the pre-fix code as making a
|
||||
// wasted ClickHouse round-trip when this is set; gate on this flag
|
||||
// instead.
|
||||
// haven't executed and have no events to fetch.
|
||||
isBuffered: boolean;
|
||||
};
|
||||
|
||||
@@ -150,7 +148,57 @@ export class ApiRetrieveRunPresenter {
|
||||
$replica
|
||||
);
|
||||
|
||||
if (pgRow) return { ...pgRow, isBuffered: false };
|
||||
if (pgRow) {
|
||||
// Dedup distinct locked-version ids so each hits the resolver exactly once
|
||||
// (unbounded childRuns mostly share the same lockedToVersionId).
|
||||
const distinctVersionIds = new Set<string>();
|
||||
const collect = (id: string | null) => {
|
||||
if (id) {
|
||||
distinctVersionIds.add(id);
|
||||
}
|
||||
};
|
||||
collect(pgRow.lockedToVersionId);
|
||||
collect(pgRow.parentTaskRun?.lockedToVersionId ?? null);
|
||||
collect(pgRow.rootTaskRun?.lockedToVersionId ?? null);
|
||||
for (const child of pgRow.childRuns) {
|
||||
collect(child.lockedToVersionId);
|
||||
}
|
||||
|
||||
const versionById = new Map<string, string | null>(
|
||||
await Promise.all(
|
||||
[...distinctVersionIds].map(
|
||||
async (id) =>
|
||||
[
|
||||
id,
|
||||
(await controlPlaneResolver.resolveRunLockedWorker({ lockedToVersionId: id }))
|
||||
?.lockedToVersion?.version ?? null,
|
||||
] as const
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const resolveVersion = (id: string | null): { version: string } | null => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const version = versionById.get(id) ?? null;
|
||||
return version !== null ? { version } : null;
|
||||
};
|
||||
|
||||
const foldVersion = (run: CommonRelatedRun): CommonRelatedRunWithVersion => ({
|
||||
...run,
|
||||
lockedToVersion: resolveVersion(run.lockedToVersionId),
|
||||
});
|
||||
|
||||
return {
|
||||
...pgRow,
|
||||
lockedToVersion: resolveVersion(pgRow.lockedToVersionId),
|
||||
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
|
||||
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
|
||||
childRuns: pgRow.childRuns.map((child) => foldVersion(child)),
|
||||
isBuffered: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Postgres miss → fall back to the mollifier buffer. When the gate
|
||||
// diverted a trigger, the run lives in Redis until the drainer replays
|
||||
@@ -499,7 +547,10 @@ async function resolveSchedule(run: CommonRelatedRun) {
|
||||
};
|
||||
}
|
||||
|
||||
async function createCommonRunStructure(run: CommonRelatedRun, apiVersion: API_VERSIONS) {
|
||||
async function createCommonRunStructure(
|
||||
run: CommonRelatedRunWithVersion,
|
||||
apiVersion: API_VERSIONS
|
||||
) {
|
||||
const metadata = await parsePacketAsJson({
|
||||
data: run.metadata ?? undefined,
|
||||
dataType: run.metadataType,
|
||||
@@ -666,6 +717,8 @@ export function synthesiseFoundRunFromBuffer(buffered: SyntheticRun): FoundRun {
|
||||
// field in the API response instead of silently dropping it until the
|
||||
// drainer materialises.
|
||||
scheduleId: buffered.scheduleId ?? null,
|
||||
// commonRunSelect now carries the scalar id; a buffered run has no locked worker yet.
|
||||
lockedToVersionId: null,
|
||||
lockedToVersion: buffered.lockedToVersion ? { version: buffered.lockedToVersion } : null,
|
||||
resumeParentOnCompletion: buffered.resumeParentOnCompletion,
|
||||
// Reconstruct the batch from the snapshot's internal id so a buffered
|
||||
|
||||
@@ -4,6 +4,7 @@ import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
import type { API_VERSIONS } from "~/api/versions";
|
||||
import { RunStatusUnspecifiedApiVersion } from "~/api/versions";
|
||||
import type { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CoercedDate } from "~/utils/zod";
|
||||
@@ -12,6 +13,15 @@ import { ApiRetrieveRunPresenter } from "./ApiRetrieveRunPresenter.server";
|
||||
import { NextRunListPresenter, type RunListOptions } from "./NextRunListPresenter.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
// Forwarded verbatim into `NextRunListPresenter` for the routed run-ops (TaskRun) reads. When
|
||||
// omitted, both clients default to the inherited `_replica` => passthrough single-DB. The
|
||||
// control-plane `runtimeEnvironment.findMany` env-scoping lookup is never routed.
|
||||
type ApiRunListPresenterReadThroughDeps = {
|
||||
newClient?: PrismaClientOrTransaction;
|
||||
legacyReplica?: PrismaClientOrTransaction;
|
||||
splitEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const ApiRunListSearchParams = z.object({
|
||||
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
|
||||
"page[after]": z.string().optional(),
|
||||
@@ -151,6 +161,14 @@ export const ApiRunListSearchParams = z.object({
|
||||
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
|
||||
|
||||
export class ApiRunListPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readThroughDeps?: ApiRunListPresenterReadThroughDeps
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call(
|
||||
project: Pick<Project, "id">,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
@@ -274,7 +292,7 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new NextRunListPresenter(this._replica, clickhouse);
|
||||
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
|
||||
|
||||
logger.debug("Calling RunListPresenter", { options });
|
||||
|
||||
|
||||
@@ -1,31 +1,57 @@
|
||||
import type { TaskRunExecutionResult } from "@trigger.dev/core/v3";
|
||||
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
|
||||
import { executionResultForTaskRun } from "~/models/taskRun.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type ApiRunResultReadThroughDeps = {
|
||||
splitEnabled?: boolean;
|
||||
newClient?: PrismaReplicaClient;
|
||||
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to this._replica.
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
isPastRetention?: (runId: string) => boolean;
|
||||
};
|
||||
|
||||
export class ApiRunResultPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
private readonly _readThrough?: ApiRunResultReadThroughDeps
|
||||
) {
|
||||
super(prisma, replica);
|
||||
}
|
||||
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<TaskRunExecutionResult | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await runStore.findRun(
|
||||
{
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
const findRun = (client: PrismaReplicaClient) =>
|
||||
client.taskRun.findFirst({
|
||||
where: { friendlyId, runtimeEnvironmentId: env.id },
|
||||
include: { attempts: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
|
||||
// Single-run result poll routed through run-ops read-through. Split on: primary store first,
|
||||
// then the secondary read replica for runs that miss on new; past-retention ids return
|
||||
// undefined -> the route's normal 404. Split off (single-DB / self-host): readThroughRun does
|
||||
// one plain findFirst against the single client (passthrough).
|
||||
const result = await readThroughRun({
|
||||
runId: friendlyId,
|
||||
environmentId: env.id,
|
||||
readNew: findRun,
|
||||
readLegacy: findRun,
|
||||
deps: {
|
||||
splitEnabled: this._readThrough?.splitEnabled,
|
||||
newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient),
|
||||
legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient),
|
||||
isPastRetention: this._readThrough?.isPastRetention,
|
||||
},
|
||||
{
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
this._prisma
|
||||
);
|
||||
});
|
||||
|
||||
const taskRun =
|
||||
result.source === "new" || result.source === "legacy-replica" ? result.value : undefined;
|
||||
|
||||
if (!taskRun) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type RuntimeEnvironmentType, WaitpointTokenStatus } from "@trigger.dev/core/v3";
|
||||
import { type RunEngineVersion } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { CoercedDate } from "~/utils/zod";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
@@ -60,6 +61,18 @@ export const ApiWaitpointListSearchParams = z.object({
|
||||
type ApiWaitpointListSearchParams = z.infer<typeof ApiWaitpointListSearchParams>;
|
||||
|
||||
export class ApiWaitpointListPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readRoute?: {
|
||||
runOpsNew?: PrismaClientOrTransaction;
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction;
|
||||
splitEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: {
|
||||
id: string;
|
||||
@@ -115,7 +128,7 @@ export class ApiWaitpointListPresenter extends BasePresenter {
|
||||
options.to = searchParams["filter[createdAt][to]"].getTime();
|
||||
}
|
||||
|
||||
const presenter = new WaitpointListPresenter();
|
||||
const presenter = new WaitpointListPresenter(undefined, undefined, this.readRoute);
|
||||
const result = await presenter.call(options);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -4,8 +4,28 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
|
||||
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
|
||||
// When omitted, clients default to the inherited _replica handle => passthrough reads the
|
||||
// replica exactly as today. isPastRetention is injectable for tests. Typed PrismaReplicaClient
|
||||
// to match readThroughRun's readNew/readLegacy + deps.
|
||||
type ApiWaitpointPresenterReadThroughDeps = {
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
splitEnabled?: boolean;
|
||||
isPastRetention?: (id: string) => boolean;
|
||||
};
|
||||
|
||||
export class ApiWaitpointPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: {
|
||||
id: string;
|
||||
@@ -19,36 +39,56 @@ export class ApiWaitpointPresenter extends BasePresenter {
|
||||
waitpointId: string
|
||||
) {
|
||||
return this.trace("call", async (span) => {
|
||||
const waitpoint = await this._replica.waitpoint.findFirst({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
connectedRuns: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
take: 5,
|
||||
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
|
||||
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
|
||||
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
|
||||
// (passthrough). The waitpointId is the residency-classifiable KSUID id (the route
|
||||
// pre-decodes the friendlyId via WaitpointId.toId).
|
||||
const hydrate = (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
tags: true,
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: waitpointId,
|
||||
environmentId: environment.id,
|
||||
readNew: (client) => hydrate(client),
|
||||
readLegacy: (replica) => hydrate(replica),
|
||||
deps: {
|
||||
splitEnabled: this.readThroughDeps?.splitEnabled,
|
||||
// Default both clients to the inherited _replica handle (declared
|
||||
// PrismaClientOrTransaction but $replica at runtime) so passthrough reads the replica
|
||||
// as today; split mode injects a distinct newClient.
|
||||
newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient),
|
||||
legacyReplica:
|
||||
this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient),
|
||||
isPastRetention: this.readThroughDeps?.isPastRetention,
|
||||
},
|
||||
});
|
||||
|
||||
const waitpoint =
|
||||
result.source === "new" || result.source === "legacy-replica" ? result.value : null;
|
||||
|
||||
if (!waitpoint) {
|
||||
logger.error(`WaitpointPresenter: Waitpoint not found`, {
|
||||
id: waitpointId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
@@ -28,7 +28,113 @@ export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
|
||||
export type BatchListItem = BatchList["batches"][0];
|
||||
export type BatchListAppliedFilters = BatchList["filters"];
|
||||
|
||||
// The row shape of the raw BatchTaskRun keyset scan. Extracted to a named type so the
|
||||
// store-selected scan closure and the keyset merge in `#scanBatchTaskRun` can reference it.
|
||||
type BatchRow = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
status: BatchTaskRunStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
completedAt: Date | null;
|
||||
runCount: number;
|
||||
batchVersion: string;
|
||||
};
|
||||
|
||||
export class BatchListPresenter extends BasePresenter {
|
||||
// Optional run-ops read-routing. Omitted (single-DB / self-host) => everything
|
||||
// reads from `_replica` exactly as today (passthrough). Field names are local to
|
||||
// this presenter; only the read-routing convention (optional handles, default-to-_replica,
|
||||
// boot-constant splitEnabled) is mirrored, not the literal RunsRepositoryOptions names.
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readRoute?: {
|
||||
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
|
||||
controlPlaneReplica?: PrismaClientOrTransaction; // control-plane DB (for project)
|
||||
splitEnabled?: boolean; // resolved boot constant
|
||||
}
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
// Control-plane READ handle for the `project` lookup. In single-DB / when omitted this is
|
||||
// `_replica` ⇒ unchanged.
|
||||
get #controlPlaneReplica(): PrismaClientOrTransaction {
|
||||
return this.readRoute?.controlPlaneReplica ?? this._replica;
|
||||
}
|
||||
|
||||
// Run-ops reads for the Batches dashboard. Split on: new run-ops DB first; the LEGACY
|
||||
// RUN-OPS READ REPLICA ONLY for the older not-yet-migrated remainder/empty-state — never the
|
||||
// legacy primary. Split off (single-DB / self-host): one plain `_replica` read (passthrough).
|
||||
// `project` is resolved on the control-plane DB; the environment↔batch join is in-memory (no
|
||||
// cross-seam SQL join).
|
||||
async #scanBatchTaskRun(
|
||||
pageSize: number,
|
||||
direction: Direction,
|
||||
scan: (client: PrismaClientOrTransaction) => Promise<BatchRow[]>
|
||||
): Promise<BatchRow[]> {
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica);
|
||||
}
|
||||
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
|
||||
|
||||
// New DB filled the page — skip the legacy read entirely; older rows fall on a later page.
|
||||
if (newRows.length >= pageSize + 1) {
|
||||
return newRows;
|
||||
}
|
||||
|
||||
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
|
||||
|
||||
// De-dupe by id (new wins), re-sort under the page's keyset order, re-apply the over-fetch
|
||||
// LIMIT — reproduces the pageSize+1 window a single union scan would return.
|
||||
const byId = new Map<string, BatchRow>();
|
||||
for (const row of newRows) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
for (const row of legacyRows) {
|
||||
if (!byId.has(row.id)) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or ksuid).
|
||||
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
|
||||
return Array.from(byId.values())
|
||||
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))
|
||||
.slice(0, pageSize + 1);
|
||||
}
|
||||
|
||||
// Empty-state probe. Split on: probe the new run-ops DB first, then the legacy READ REPLICA only
|
||||
// (never the legacy primary). Split off (single-DB / self-host): one plain `_replica` probe.
|
||||
async #probeAnyBatch(environmentId: string): Promise<boolean> {
|
||||
// Passthrough: probe the SAME client the scan uses (_replica), or the empty-state hint can
|
||||
// disagree with the page when a run-ops DB is configured but read-split is off.
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
const onReplica = await this._replica.batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
return Boolean(onReplica);
|
||||
}
|
||||
|
||||
const onNew = await (this.readRoute.runOpsNew ?? this._replica).batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
if (onNew) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const onLegacy = await (
|
||||
this.readRoute.runOpsLegacyReplica ?? this._replica
|
||||
).batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
return Boolean(onLegacy);
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
@@ -53,8 +159,7 @@ export class BatchListPresenter extends BasePresenter {
|
||||
|
||||
const hasFilters = hasStatusFilters || friendlyId !== undefined || !time.isDefault;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
const project = await this.#controlPlaneReplica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
@@ -83,66 +188,53 @@ export class BatchListPresenter extends BasePresenter {
|
||||
|
||||
const periodMs = time.period ? parse(time.period) : undefined;
|
||||
|
||||
//get the batches
|
||||
const batches = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
status: BatchTaskRunStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
completedAt: Date | null;
|
||||
runCount: bigint;
|
||||
batchVersion: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
b.id,
|
||||
b."friendlyId",
|
||||
b."runtimeEnvironmentId",
|
||||
b.status,
|
||||
b."createdAt",
|
||||
b."updatedAt",
|
||||
b."completedAt",
|
||||
b."runCount",
|
||||
b."batchVersion"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BatchTaskRun" b
|
||||
WHERE
|
||||
-- environment
|
||||
b."runtimeEnvironmentId" = ${environmentId}
|
||||
-- cursor
|
||||
${
|
||||
cursor
|
||||
? direction === "forward"
|
||||
? Prisma.sql`AND b.id < ${cursor}`
|
||||
: Prisma.sql`AND b.id > ${cursor}`
|
||||
: Prisma.empty
|
||||
let createdAtGte: Date | undefined;
|
||||
if (periodMs != null) {
|
||||
createdAtGte = new Date(Date.now() - periodMs);
|
||||
}
|
||||
-- filters
|
||||
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
|
||||
${
|
||||
statuses && statuses.length > 0
|
||||
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
|
||||
statuses
|
||||
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
|
||||
: Prisma.empty
|
||||
if (time.from !== undefined) {
|
||||
createdAtGte =
|
||||
createdAtGte === undefined
|
||||
? time.from
|
||||
: time.from > createdAtGte
|
||||
? time.from
|
||||
: createdAtGte;
|
||||
}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
time.from
|
||||
? Prisma.sql`AND b."createdAt" >= ${time.from.toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
${time.to ? Prisma.sql`AND b."createdAt" <= ${time.to.toISOString()}::timestamp` : Prisma.empty}
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
const createdAtLte: Date | undefined = time.to;
|
||||
|
||||
const batches = await this.#scanBatchTaskRun(pageSize, direction, (client) =>
|
||||
client.batchTaskRun.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
|
||||
...(friendlyId ? { friendlyId } : {}),
|
||||
...(statuses && statuses.length > 0
|
||||
? { status: { in: statuses }, batchVersion: { not: "v1" } }
|
||||
: {}),
|
||||
...(createdAtGte !== undefined || createdAtLte !== undefined
|
||||
? {
|
||||
createdAt: {
|
||||
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
|
||||
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { id: direction === "forward" ? "desc" : "asc" },
|
||||
take: pageSize + 1,
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
runtimeEnvironmentId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const hasMore = batches.length > pageSize;
|
||||
|
||||
@@ -174,15 +266,7 @@ WHERE
|
||||
|
||||
let hasAnyBatches = batchesToReturn.length > 0;
|
||||
if (!hasAnyBatches) {
|
||||
const firstBatch = await this._replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (firstBatch) {
|
||||
hasAnyBatches = true;
|
||||
}
|
||||
hasAnyBatches = await this.#probeAnyBatch(environmentId);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { type BatchTaskRunStatus, type Prisma } from "@trigger.dev/database";
|
||||
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
@@ -9,63 +11,92 @@ type BatchPresenterOptions = {
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
// Shared by the read-through closures and the passthrough so every store path returns
|
||||
// a byte-identical row shape.
|
||||
const BATCH_SELECT = {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.BatchTaskRunSelect;
|
||||
|
||||
type BatchRow = Prisma.BatchTaskRunGetPayload<{ select: typeof BATCH_SELECT }>;
|
||||
|
||||
type BatchPresenterDeps = {
|
||||
/** Resolved boot constant; never awaited per-request when supplied. */
|
||||
splitEnabled?: boolean;
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
readThrough?: typeof readThroughRun;
|
||||
resolveDisplayableEnvironment?: typeof findDisplayableEnvironment;
|
||||
};
|
||||
|
||||
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
|
||||
|
||||
export class BatchPresenter extends BasePresenter {
|
||||
constructor(
|
||||
_prisma?: PrismaClientOrTransaction,
|
||||
_replica?: PrismaClientOrTransaction,
|
||||
private readonly deps: BatchPresenterDeps = {}
|
||||
) {
|
||||
super(_prisma, _replica);
|
||||
}
|
||||
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
friendlyId: batchId,
|
||||
// Reads the BatchTaskRun (run-ops) via the read-through layer: split on -> new run-ops
|
||||
// first, then the LEGACY RUN-OPS READ REPLICA only for not-yet-migrated batches (never the
|
||||
// legacy primary); split off (single-DB / self-host) -> one plain batchTaskRun.findFirst
|
||||
// (passthrough). The runtimeEnvironment (control-plane) is resolved separately because its
|
||||
// FK is physically dropped on cloud, so a batch row on the new run-ops DB cannot single-SQL
|
||||
// join to control-plane RuntimeEnvironment.
|
||||
const where = { runtimeEnvironmentId: environmentId, friendlyId: batchId } as const;
|
||||
const readBatch = (client: PrismaReplicaClient): Promise<BatchRow | null> =>
|
||||
client.batchTaskRun.findFirst({ select: BATCH_SELECT, where });
|
||||
|
||||
const readThrough = this.deps.readThrough ?? readThroughRun;
|
||||
const batchResult = await readThrough<BatchRow>({
|
||||
// The read-through key; here it is the batch friendlyId. A cuid-shaped batch friendlyId
|
||||
// classifies as LEGACY and the read-through probes both stores (new first, then legacy
|
||||
// replica); a ksuid-shaped one (cut-over orgs) classifies as NEW and reads only the new
|
||||
// store — either way the row is found on the DB that owns it.
|
||||
runId: batchId,
|
||||
environmentId,
|
||||
readNew: readBatch,
|
||||
readLegacy: readBatch,
|
||||
deps: {
|
||||
splitEnabled: this.deps.splitEnabled,
|
||||
newClient: this.deps.newClient,
|
||||
legacyReplica: this.deps.legacyReplica,
|
||||
},
|
||||
});
|
||||
|
||||
const batch =
|
||||
batchResult.source === "new" || batchResult.source === "legacy-replica"
|
||||
? batchResult.value
|
||||
: null; // not-found / past-retention => normal not-found surface
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
}
|
||||
@@ -86,6 +117,9 @@ export class BatchPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
// Control-plane env resolved separately from the run-ops batch row (cross-seam FK dropped).
|
||||
const resolveEnv = this.deps.resolveDisplayableEnvironment ?? findDisplayableEnvironment;
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
@@ -107,7 +141,7 @@ export class BatchPresenter extends BasePresenter {
|
||||
successfulRunCount: liveSuccessCount,
|
||||
failedRunCount: liveFailureCount,
|
||||
idempotencyKey: batch.idempotencyKey,
|
||||
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
|
||||
environment: await resolveEnv(environmentId, userId),
|
||||
errors: batch.errors.map((error) => ({
|
||||
id: error.id,
|
||||
index: error.index,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
|
||||
import { machinePresetFromRun } from "~/v3/machinePresets.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
@@ -54,9 +53,51 @@ export type NextRunListAppliedFilters = NextRunList["filters"];
|
||||
export class NextRunListPresenter {
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
private readonly clickhouse: ClickHouse,
|
||||
private readonly readThroughDeps?: {
|
||||
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
|
||||
// Omitted => single-DB / self-host: both default to `replica` (passthrough).
|
||||
newClient?: PrismaClientOrTransaction;
|
||||
legacyReplica?: PrismaClientOrTransaction;
|
||||
// Resolved boot constant from isSplitEnabled(). When false/absent:
|
||||
// list hydrate runs passthrough and the empty-state probe is one plain findFirst.
|
||||
splitEnabled?: boolean;
|
||||
}
|
||||
) {}
|
||||
|
||||
// Existence probe for the empty-state. run-ops (TaskRun) read.
|
||||
// Split off / single-DB: one plain findFirst on `replica` (passthrough).
|
||||
// Split on: true if a row exists in the NEW run-ops DB OR the LEGACY run-ops
|
||||
// read replica only (never the legacy writer). New first to avoid touching
|
||||
// legacy whenever the new DB already answers.
|
||||
async #anyRunExistsInEnv(environmentId: string): Promise<boolean> {
|
||||
const splitEnabled = this.readThroughDeps?.splitEnabled ?? false;
|
||||
|
||||
if (!splitEnabled) {
|
||||
const firstRun = await this.replica.taskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
return firstRun !== null;
|
||||
}
|
||||
|
||||
const newClient = this.readThroughDeps?.newClient ?? this.replica;
|
||||
const newRun = await newClient.taskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (newRun) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const legacyReplica = this.readThroughDeps?.legacyReplica ?? this.replica;
|
||||
const legacyRun = await legacyReplica.taskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
return legacyRun !== null;
|
||||
}
|
||||
|
||||
public async call(
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
@@ -113,10 +154,8 @@ export class NextRunListPresenter {
|
||||
rootOnly === true ||
|
||||
!time.isDefault;
|
||||
|
||||
//get all possible tasks
|
||||
const possibleTasksAsync = getTaskIdentifiers(environmentId);
|
||||
|
||||
//get possible bulk actions
|
||||
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
|
||||
select: {
|
||||
friendlyId: true,
|
||||
@@ -168,6 +207,13 @@ export class NextRunListPresenter {
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica as PrismaClient,
|
||||
readThrough: this.readThroughDeps
|
||||
? {
|
||||
newClient: this.readThroughDeps.newClient ?? this.replica,
|
||||
legacyReplica: this.readThroughDeps.legacyReplica ?? this.replica,
|
||||
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
function clampToNow(date: Date): Date {
|
||||
@@ -207,16 +253,7 @@ export class NextRunListPresenter {
|
||||
let hasAnyRuns = runs.length > 0;
|
||||
|
||||
if (!hasAnyRuns) {
|
||||
const firstRun = await runStore.findRun(
|
||||
{
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
this.replica
|
||||
);
|
||||
|
||||
if (firstRun) {
|
||||
hasAnyRuns = true;
|
||||
}
|
||||
hasAnyRuns = await this.#anyRunExistsInEnv(environmentId);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { getEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
|
||||
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
|
||||
export type Run = Result["run"];
|
||||
@@ -64,22 +65,22 @@ export class RunPresenter {
|
||||
// buffer view. `findFirstOrThrow` would log a `PrismaClient error`
|
||||
// every tick of the page poll, masking real DB issues with synthetic
|
||||
// not-found noise.
|
||||
//
|
||||
// No explicit client arg: the store reads off its own replica and routes by
|
||||
// residency once a RoutingRunStore is injected. Pinning this.#prismaClient
|
||||
// would override that routing. (The user.findFirst admin check below stays on
|
||||
// the control-plane client.)
|
||||
// Run-ops read keyed by friendlyId only — routes to the owning DB by residency.
|
||||
// The project-scope + membership auth is a control-plane concern resolved
|
||||
// separately below; joining project/organization here is a cross-DB join that
|
||||
// returns nothing once the run lives in the run-ops DB.
|
||||
const run = await runStore.findRun(
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
select: {
|
||||
projectId: true,
|
||||
id: true,
|
||||
createdAt: true,
|
||||
taskEventStore: true,
|
||||
@@ -108,35 +109,42 @@ export class RunPresenter {
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
organizationId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
},
|
||||
this.#prismaClient
|
||||
}
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
throw new RunNotInPgError(runFriendlyId);
|
||||
}
|
||||
|
||||
if (environmentSlug !== run.runtimeEnvironment.slug) {
|
||||
// Project-scope + membership auth is control-plane only — verify the run's
|
||||
// project matches the requested slug and the user is a member, keyed by the
|
||||
// run's projectId. A miss is treated as not-found (mirrors the old scoped where).
|
||||
const authorizedProject = await this.#prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: run.projectId,
|
||||
slug: projectSlug,
|
||||
organization: { members: { some: { userId } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!authorizedProject) {
|
||||
throw new RunNotInPgError(runFriendlyId);
|
||||
}
|
||||
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
|
||||
run.runtimeEnvironmentId
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
// An unresolvable control-plane env means the run can't be presented from PG;
|
||||
// mirror the not-found path the route already handles (mollifier buffer fallback).
|
||||
throw new RunNotInPgError(runFriendlyId);
|
||||
}
|
||||
|
||||
if (environmentSlug !== environment.slug) {
|
||||
throw new RunEnvironmentMismatchError(
|
||||
`Run ${runFriendlyId} is not in environment ${environmentSlug}`
|
||||
);
|
||||
@@ -158,12 +166,12 @@ export class RunPresenter {
|
||||
rootTaskRun: run.rootTaskRun,
|
||||
parentTaskRun: run.parentTaskRun,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
|
||||
id: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userId: environment.orgMember?.user?.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -177,7 +185,7 @@ export class RunPresenter {
|
||||
|
||||
const repository = await getEventRepositoryForStore(
|
||||
run.taskEventStore,
|
||||
run.runtimeEnvironment.organizationId
|
||||
environment.organizationId
|
||||
);
|
||||
|
||||
const traceTimeBounds = {
|
||||
@@ -189,7 +197,7 @@ export class RunPresenter {
|
||||
// span fell past the row cap (large traces ordered by start_time ASC).
|
||||
let traceSummary = await repository.getTraceSummary(
|
||||
getTaskEventStoreTableForRun(run),
|
||||
run.runtimeEnvironment.id,
|
||||
environment.id,
|
||||
run.traceId,
|
||||
traceTimeBounds.startCreatedAt,
|
||||
traceTimeBounds.endCreatedAt,
|
||||
@@ -209,7 +217,7 @@ export class RunPresenter {
|
||||
|
||||
const subtreeSummary = await repository.getTraceSubtreeSummary(
|
||||
getTaskEventStoreTableForRun(run),
|
||||
run.runtimeEnvironment.id,
|
||||
environment.id,
|
||||
run.traceId,
|
||||
run.spanId,
|
||||
traceTimeBounds.startCreatedAt,
|
||||
@@ -260,6 +268,8 @@ export class RunPresenter {
|
||||
};
|
||||
}
|
||||
|
||||
// Control-plane read (User table) — stays on the control-plane client, NOT
|
||||
// routed through the run-ops store (user resolved CP-side, run run-ops-side).
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
|
||||
@@ -38,34 +38,42 @@ export class RunStreamPresenter {
|
||||
// Scope the lookup to organizations the requesting user is a member
|
||||
// of, matching RunPresenter's run lookup. Unauthorized and missing
|
||||
// runs are indistinguishable (both 404).
|
||||
// Run-ops read by friendlyId only (routes to the owning DB); the project/org
|
||||
// membership auth is a control-plane concern resolved separately below — joining
|
||||
// it here is a cross-DB join that returns nothing once the run lives in run-ops.
|
||||
const run = await runStore.findRun(
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
project: {
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
select: {
|
||||
traceId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
prismaClient
|
||||
}
|
||||
);
|
||||
|
||||
// Authorize on the control-plane DB, keyed by the run's project. A non-member
|
||||
// (or unresolvable project) is treated as no-access: traceId stays null.
|
||||
let authorized = false;
|
||||
if (run) {
|
||||
const authorizedProject = await prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: run.projectId,
|
||||
organization: { members: { some: { userId } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
authorized = authorizedProject !== null;
|
||||
}
|
||||
|
||||
// Fall back to the mollifier buffer when the run isn't in PG yet.
|
||||
// The buffered run has no execution events to stream, but we still
|
||||
// attach a trace-pubsub subscription using the snapshot's traceId
|
||||
// so that the moment the drainer materialises the row and execution
|
||||
// begins, those events flow to this open SSE connection. Closing
|
||||
// with 404 would force the dashboard to keep retrying.
|
||||
let traceId: string | null = run?.traceId ?? null;
|
||||
let traceId: string | null = run && authorized ? run.traceId : null;
|
||||
if (!traceId) {
|
||||
const buffer = getMollifierBuffer();
|
||||
if (buffer) {
|
||||
@@ -107,7 +115,6 @@ export class RunStreamPresenter {
|
||||
traceId: resolvedRun.traceId,
|
||||
});
|
||||
|
||||
// Subscribe to trace updates
|
||||
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(
|
||||
resolvedRun.traceId
|
||||
);
|
||||
@@ -142,10 +149,8 @@ export class RunStreamPresenter {
|
||||
|
||||
return {
|
||||
initStream: ({ send }) => {
|
||||
// Create throttled send function
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
|
||||
// Set up message listener for pub/sub events
|
||||
messageListener = (event: string) => {
|
||||
throttledSend({ send, event: "message", data: event });
|
||||
};
|
||||
@@ -155,7 +160,6 @@ export class RunStreamPresenter {
|
||||
},
|
||||
|
||||
iterator: ({ send }) => {
|
||||
// Send ping to keep connection alive
|
||||
try {
|
||||
// Send an actual message so the client refreshes
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
@@ -171,13 +175,11 @@ export class RunStreamPresenter {
|
||||
traceId: resolvedRun.traceId,
|
||||
});
|
||||
|
||||
// Remove message listener
|
||||
if (messageListener) {
|
||||
eventEmitter.removeListener("message", messageListener);
|
||||
}
|
||||
eventEmitter.removeAllListeners();
|
||||
|
||||
// Unsubscribe from Redis pub/sub
|
||||
unsubscribe()
|
||||
.then(() => {
|
||||
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
|
||||
@@ -203,7 +205,6 @@ export class RunStreamPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton loader for the route to use
|
||||
export const runStreamLoader = singleton("runStreamLoader", () => {
|
||||
const presenter = new RunStreamPresenter();
|
||||
return presenter.createLoader();
|
||||
|
||||
@@ -36,6 +36,11 @@ import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/tas
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { WaitpointPresenter } from "./WaitpointPresenter.server";
|
||||
import {
|
||||
controlPlaneResolver,
|
||||
type ResolvedRunLockedWorker,
|
||||
} from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
|
||||
export type PromptSpanData = {
|
||||
slug: string;
|
||||
@@ -76,16 +81,10 @@ function extractPromptSpanData(properties: Record<string, unknown>): PromptSpanD
|
||||
};
|
||||
}
|
||||
|
||||
// SpanRun is grounded in the PG-path `getRun` method rather than
|
||||
// inferred from `call`'s return type. The buffered branch of `call`
|
||||
// routes through `buildSyntheticSpanRun`, and that helper is annotated
|
||||
// `Promise<SpanRun>` — if SpanRun were derived from `call` it would
|
||||
// close a loop TS no longer tolerates ("Type alias 'Result' circularly
|
||||
// references itself"). `getRun` is the canonical source for the shape
|
||||
// (the synthetic helper just rebuilds the same shape from a buffer
|
||||
// snapshot), and it doesn't recurse, so grounding here breaks the
|
||||
// cycle while keeping Span available off `call` (Span's path through
|
||||
// `#getSpan` has no synthetic indirection).
|
||||
// Grounded in `getRun` (the canonical shape source), not inferred from `call`:
|
||||
// `call`'s buffered branch returns `buildSyntheticSpanRun` which is annotated
|
||||
// `Promise<SpanRun>`, so deriving SpanRun from `call` would be a circular type
|
||||
// reference TS rejects. `getRun` doesn't recurse, breaking the cycle.
|
||||
export type SpanRun = NonNullable<
|
||||
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["getRun"]>>
|
||||
>;
|
||||
@@ -94,6 +93,10 @@ export type Span = NonNullable<NonNullable<Result>["span"]>;
|
||||
type FindRunResult = NonNullable<
|
||||
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
|
||||
>;
|
||||
|
||||
// Run-ops TaskRun reads (parent run in `call`, hydrate in `findRun`, children in
|
||||
// `#getSpan`) go through the `runStore` seam; split routing is the RoutingRunStore's
|
||||
// job below it. Control-plane reads stay on `this._replica`/`this._prisma`.
|
||||
export class SpanPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
@@ -251,18 +254,31 @@ export class SpanPresenter extends BasePresenter {
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(
|
||||
run.runtimeEnvironmentId
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lockedWorker = await controlPlaneResolver.resolveRunLockedWorker({
|
||||
lockedById: run.lockedById,
|
||||
lockedToVersionId: run.lockedToVersionId,
|
||||
});
|
||||
|
||||
const isFinished = isFinalRunStatus(run.status);
|
||||
const output = !isFinished
|
||||
? undefined
|
||||
: run.outputType === "application/store"
|
||||
? `/resources/packets/${run.runtimeEnvironment.id}/${run.output}`
|
||||
? `/resources/packets/${environment.id}/${run.output}`
|
||||
: typeof run.output !== "undefined" && run.output !== null
|
||||
? await prettyPrintPacket(run.output, run.outputType ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const payload =
|
||||
run.payloadType === "application/store"
|
||||
? `/resources/packets/${run.runtimeEnvironment.id}/${run.payload}`
|
||||
? `/resources/packets/${environment.id}/${run.payload}`
|
||||
: typeof run.payload !== "undefined" && run.payload !== null
|
||||
? await prettyPrintPacket(run.payload, run.payloadType ?? undefined)
|
||||
: undefined;
|
||||
@@ -289,7 +305,12 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
const machine = run.machinePreset ? machinePresetFromRun(run) : undefined;
|
||||
|
||||
const context = await this.#getTaskRunContext({ run, machine: machine ?? undefined });
|
||||
const context = await this.#getTaskRunContext({
|
||||
run,
|
||||
machine: machine ?? undefined,
|
||||
environment,
|
||||
lockedWorker,
|
||||
});
|
||||
|
||||
const externalTraceId = this.#getExternalTraceId(run.traceContext);
|
||||
|
||||
@@ -299,7 +320,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
let region: { name: string; location: string | null } | null = null;
|
||||
|
||||
if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") {
|
||||
if (environment.type !== "DEVELOPMENT" && run.engine !== "V1") {
|
||||
const workerGroup = await this._replica.workerInstanceGroup.findFirst({
|
||||
select: {
|
||||
name: true,
|
||||
@@ -319,10 +340,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Only AGENT-tagged runs (chat.agent and friends) can be session-bound,
|
||||
// so skip the SessionRun lookup for the much larger set of standard runs.
|
||||
// Lookup is by the unique `runId` index, but the cheapest query is the
|
||||
// one we don't run.
|
||||
// Only AGENT-tagged runs can be session-bound, so skip the SessionRun lookup
|
||||
// for the much larger set of standard runs — the cheapest query is the one we
|
||||
// don't run.
|
||||
const sessionRun = isAgentRun
|
||||
? await this._replica.sessionRun.findFirst({
|
||||
where: { runId: run.id },
|
||||
@@ -376,13 +396,13 @@ export class SpanPresenter extends BasePresenter {
|
||||
logsDeletedAt: run.logsDeletedAt,
|
||||
ttl: run.ttl,
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
version: run.lockedToVersion?.version,
|
||||
sdkVersion: run.lockedToVersion?.sdkVersion,
|
||||
runtime: run.lockedToVersion?.runtime,
|
||||
runtimeVersion: run.lockedToVersion?.runtimeVersion,
|
||||
version: lockedWorker?.lockedToVersion?.version,
|
||||
sdkVersion: lockedWorker?.lockedToVersion?.sdkVersion,
|
||||
runtime: lockedWorker?.lockedToVersion?.runtime,
|
||||
runtimeVersion: lockedWorker?.lockedToVersion?.runtimeVersion,
|
||||
isTest: run.isTest,
|
||||
replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId,
|
||||
environmentId: run.runtimeEnvironment.id,
|
||||
environmentId: environment.id,
|
||||
idempotencyKey: getUserProvidedIdempotencyKey(run),
|
||||
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
|
||||
idempotencyKeyScope: extractIdempotencyKeyScope(run),
|
||||
@@ -511,6 +531,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
{
|
||||
select: {
|
||||
id: true,
|
||||
runtimeEnvironmentId: true,
|
||||
lockedById: true,
|
||||
lockedToVersionId: true,
|
||||
spanId: true,
|
||||
traceId: true,
|
||||
traceContext: true,
|
||||
@@ -523,14 +546,6 @@ export class SpanPresenter extends BasePresenter {
|
||||
taskEventStore: true,
|
||||
runTags: true,
|
||||
machinePreset: true,
|
||||
lockedToVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
runtime: true,
|
||||
runtimeVersion: true,
|
||||
},
|
||||
},
|
||||
engine: true,
|
||||
workerQueue: true,
|
||||
region: true,
|
||||
@@ -567,26 +582,12 @@ export class SpanPresenter extends BasePresenter {
|
||||
baseCostInCents: true,
|
||||
costInCents: true,
|
||||
usageDurationMs: true,
|
||||
//env
|
||||
runtimeEnvironment: {
|
||||
select: { id: true, slug: true, type: true },
|
||||
},
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
metadata: true,
|
||||
metadataType: true,
|
||||
annotations: true,
|
||||
maxAttempts: true,
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
lockedBy: {
|
||||
select: {
|
||||
filePath: true,
|
||||
},
|
||||
},
|
||||
//relationships
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
@@ -721,7 +722,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const presenter = new WaitpointPresenter();
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {});
|
||||
const waitpoint = await presenter.call({
|
||||
friendlyId: span.entity.id,
|
||||
environmentId,
|
||||
@@ -968,9 +969,19 @@ export class SpanPresenter extends BasePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
async #getTaskRunContext({ run, machine }: { run: FindRunResult; machine?: MachinePreset }) {
|
||||
async #getTaskRunContext({
|
||||
run,
|
||||
machine,
|
||||
environment,
|
||||
lockedWorker,
|
||||
}: {
|
||||
run: FindRunResult;
|
||||
machine?: MachinePreset;
|
||||
environment: AuthenticatedEnvironment;
|
||||
lockedWorker: ResolvedRunLockedWorker | null;
|
||||
}) {
|
||||
if (run.engine === "V1") {
|
||||
return this.#getV3TaskRunContext({ run, machine });
|
||||
return this.#getV3TaskRunContext({ run, machine, environment, lockedWorker });
|
||||
} else {
|
||||
return this.#getV4TaskRunContext({ run });
|
||||
}
|
||||
@@ -979,9 +990,13 @@ export class SpanPresenter extends BasePresenter {
|
||||
async #getV3TaskRunContext({
|
||||
run,
|
||||
machine,
|
||||
environment,
|
||||
lockedWorker,
|
||||
}: {
|
||||
run: FindRunResult;
|
||||
machine?: MachinePreset;
|
||||
environment: AuthenticatedEnvironment;
|
||||
lockedWorker: ResolvedRunLockedWorker | null;
|
||||
}): Promise<V3TaskRunContext> {
|
||||
const attempt = run.attempts[0];
|
||||
|
||||
@@ -1001,7 +1016,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
task: {
|
||||
id: run.taskIdentifier,
|
||||
filePath: run.lockedBy?.filePath ?? "",
|
||||
filePath: lockedWorker?.lockedBy?.filePath ?? "",
|
||||
},
|
||||
run: {
|
||||
id: run.friendlyId,
|
||||
@@ -1015,7 +1030,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
maxAttempts: run.maxAttempts ?? undefined,
|
||||
version: run.lockedToVersion?.version,
|
||||
version: lockedWorker?.lockedToVersion?.version,
|
||||
maxDuration: run.maxDurationInSeconds ?? undefined,
|
||||
},
|
||||
queue: {
|
||||
@@ -1023,20 +1038,20 @@ export class SpanPresenter extends BasePresenter {
|
||||
id: run.queue,
|
||||
},
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
type: run.runtimeEnvironment.type,
|
||||
id: environment.id,
|
||||
slug: environment.slug,
|
||||
type: environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.project.organization.id,
|
||||
slug: run.project.organization.slug,
|
||||
name: run.project.organization.title,
|
||||
id: environment.organization.id,
|
||||
slug: environment.organization.slug,
|
||||
name: environment.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: run.project.id,
|
||||
ref: run.project.externalRef,
|
||||
slug: run.project.slug,
|
||||
name: run.project.name,
|
||||
id: environment.project.id,
|
||||
ref: environment.project.externalRef,
|
||||
slug: environment.project.slug,
|
||||
name: environment.project.name,
|
||||
},
|
||||
machine,
|
||||
} satisfies V3TaskRunContext;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClickHouse } from "@internal/clickhouse";
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
type Prisma,
|
||||
type PrismaClientOrTransaction,
|
||||
type RuntimeEnvironmentType,
|
||||
type TaskRunStatus,
|
||||
@@ -8,13 +9,48 @@ import {
|
||||
} from "@trigger.dev/database";
|
||||
import { inferSchema } from "@jsonhero/schema-infer";
|
||||
import parse from "parse-duration";
|
||||
import { type RunStore } from "@internal/run-store";
|
||||
import { type PrismaClient } from "~/db.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
|
||||
|
||||
// Optional run-ops read-through wiring for the recent-payloads hydrate. Omitted
|
||||
// => passthrough on `this.replica` (single-DB / self-host). `legacyReplica` is a
|
||||
// READ REPLICA handle only — there is no legacy-primary field.
|
||||
type TestTaskReadThroughDeps = {
|
||||
newClient?: PrismaClientOrTransaction;
|
||||
legacyReplica?: PrismaClientOrTransaction;
|
||||
// Resolved boot constant; when false the split branch is never entered.
|
||||
splitEnabled?: boolean;
|
||||
};
|
||||
|
||||
// The byte-identical select the recent-payloads hydrate has always used; `id` is
|
||||
// included so the split merge can key set-membership.
|
||||
const RECENT_RUNS_SELECT = {
|
||||
id: true,
|
||||
queue: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
seedMetadata: true,
|
||||
seedMetadataType: true,
|
||||
runtimeEnvironmentId: true,
|
||||
concurrencyKey: true,
|
||||
maxAttempts: true,
|
||||
maxDurationInSeconds: true,
|
||||
machinePreset: true,
|
||||
ttl: true,
|
||||
runTags: true,
|
||||
} as const;
|
||||
|
||||
type RecentRunRow = Prisma.TaskRunGetPayload<{ select: typeof RECENT_RUNS_SELECT }>;
|
||||
|
||||
export type RunTemplate = TaskRunTemplate & {
|
||||
scheduledTaskPayload?: ScheduledRun["payload"];
|
||||
};
|
||||
@@ -122,7 +158,9 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
|
||||
export class TestTaskPresenter {
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
private readonly clickhouse: ClickHouse,
|
||||
private readonly readThrough?: TestTaskReadThroughDeps,
|
||||
private readonly runStore: RunStore = defaultRunStore
|
||||
) {}
|
||||
|
||||
public async call({
|
||||
@@ -215,41 +253,7 @@ export class TestTaskPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const latestRuns = await runStore.findRuns(
|
||||
{
|
||||
where: {
|
||||
id: {
|
||||
in: runIds,
|
||||
},
|
||||
payloadType: {
|
||||
in: ["application/json", "application/super+json"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
queue: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
seedMetadata: true,
|
||||
seedMetadataType: true,
|
||||
runtimeEnvironmentId: true,
|
||||
concurrencyKey: true,
|
||||
maxAttempts: true,
|
||||
maxDurationInSeconds: true,
|
||||
machinePreset: true,
|
||||
ttl: true,
|
||||
runTags: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
this.replica
|
||||
);
|
||||
const latestRuns = await this.hydrateRecentRuns(runIds);
|
||||
|
||||
// Infer schema from existing run payloads when no explicit schema is defined
|
||||
let inferredPayloadSchema: unknown | undefined;
|
||||
@@ -386,6 +390,58 @@ export class TestTaskPresenter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the recent-payloads find on one client, preserving the byte-identical
|
||||
// select, the payloadType IN filter, and the createdAt-desc order on every
|
||||
// store this hydrate touches.
|
||||
private hydrateOnClient(
|
||||
client: PrismaClientOrTransaction,
|
||||
ids: string[]
|
||||
): Promise<RecentRunRow[]> {
|
||||
return this.runStore.findRuns(
|
||||
{
|
||||
where: {
|
||||
id: { in: ids },
|
||||
payloadType: { in: ["application/json", "application/super+json"] },
|
||||
},
|
||||
select: RECENT_RUNS_SELECT,
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
client
|
||||
);
|
||||
}
|
||||
|
||||
// Hydrates the recent-payloads run-id set from the run-ops store. Split on: new
|
||||
// client first, then the LEGACY READ REPLICA ONLY for ids that miss on new —
|
||||
// never the legacy primary. Split off: one plain findRuns on `this.replica`.
|
||||
private async hydrateRecentRuns(runIds: string[]): Promise<RecentRunRow[]> {
|
||||
if (runIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.readThrough?.splitEnabled) {
|
||||
return this.hydrateOnClient(this.readThrough?.newClient ?? this.replica, runIds);
|
||||
}
|
||||
|
||||
const newClient = this.readThrough.newClient ?? this.replica;
|
||||
const legacyReplica = this.readThrough.legacyReplica ?? this.replica;
|
||||
|
||||
const newRows = await this.hydrateOnClient(newClient, runIds);
|
||||
const foundIds = new Set(newRows.map((r) => r.id));
|
||||
// Probe every id that missed on new against the legacy read replica.
|
||||
const toProbeLegacy = runIds.filter((id) => !foundIds.has(id));
|
||||
|
||||
const legacyRows = toProbeLegacy.length
|
||||
? await this.hydrateOnClient(legacyReplica, toProbeLegacy)
|
||||
: [];
|
||||
|
||||
// Re-impose createdAt-desc across the two finds to match single-DB ordering,
|
||||
// with an id-desc tie-break so identical timestamps stay deterministic.
|
||||
return [...newRows, ...legacyRows].sort((a, b) => {
|
||||
const byCreatedAt = b.createdAt.getTime() - a.createdAt.getTime();
|
||||
return byCreatedAt !== 0 ? byCreatedAt : a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getScheduleTaskRunPayload(payload: string, payloadType: string) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import parse from "parse-duration";
|
||||
import {
|
||||
Prisma,
|
||||
type RunEngineVersion,
|
||||
type RuntimeEnvironmentType,
|
||||
type WaitpointStatus,
|
||||
} from "@trigger.dev/database";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
@@ -15,6 +14,23 @@ import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
// Row shape returned by the raw MANUAL-waitpoint keyset scan. Named so both the
|
||||
// scan closure and the #scanWaitpoints store-selection helper reference one type.
|
||||
type WaitpointRow = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
status: WaitpointStatus;
|
||||
completedAt: Date | null;
|
||||
completedAfter: Date | null;
|
||||
outputIsError: boolean;
|
||||
idempotencyKey: string;
|
||||
idempotencyKeyExpiresAt: Date | null;
|
||||
inactiveIdempotencyKey: string | null;
|
||||
userProvidedIdempotencyKey: boolean;
|
||||
createdAt: Date;
|
||||
tags: null | string[];
|
||||
};
|
||||
|
||||
export type WaitpointListOptions = {
|
||||
environment: {
|
||||
id: string;
|
||||
@@ -66,6 +82,21 @@ type Result =
|
||||
};
|
||||
|
||||
export class WaitpointListPresenter extends BasePresenter {
|
||||
// Optional run-ops read-routing. Omitted (single-DB / self-host) => every read
|
||||
// goes through `_replica` exactly as today (passthrough). There is NO legacy
|
||||
// writer/primary handle by construction — the legacy field is the read replica only.
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readRoute?: {
|
||||
runOpsNew?: PrismaClientOrTransaction; // new run-ops client
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction; // legacy run-ops READ REPLICA only — never the legacy primary
|
||||
splitEnabled?: boolean; // resolved boot constant
|
||||
}
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call({
|
||||
environment,
|
||||
id,
|
||||
@@ -133,91 +164,60 @@ export class WaitpointListPresenter extends BasePresenter {
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
// Get the waitpoint tokens using raw SQL for better performance
|
||||
const tokens = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
status: WaitpointStatus;
|
||||
completedAt: Date | null;
|
||||
completedAfter: Date | null;
|
||||
outputIsError: boolean;
|
||||
idempotencyKey: string;
|
||||
idempotencyKeyExpiresAt: Date | null;
|
||||
inactiveIdempotencyKey: string | null;
|
||||
userProvidedIdempotencyKey: boolean;
|
||||
createdAt: Date;
|
||||
tags: null | string[];
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
w.id,
|
||||
w."friendlyId",
|
||||
w.status,
|
||||
w."completedAt",
|
||||
w."completedAfter",
|
||||
w."outputIsError",
|
||||
w."idempotencyKey",
|
||||
w."idempotencyKeyExpiresAt",
|
||||
w."inactiveIdempotencyKey",
|
||||
w."userProvidedIdempotencyKey",
|
||||
w."tags",
|
||||
w."createdAt"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."Waitpoint" w
|
||||
WHERE
|
||||
w."environmentId" = ${environment.id}
|
||||
AND w.type = 'MANUAL'
|
||||
-- cursor
|
||||
${
|
||||
cursor
|
||||
? direction === "forward"
|
||||
? Prisma.sql`AND w.id < ${cursor}`
|
||||
: Prisma.sql`AND w.id > ${cursor}`
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${id ? Prisma.sql`AND w."friendlyId" = ${id}` : Prisma.empty}
|
||||
${
|
||||
statusesToFilter && statusesToFilter.length > 0
|
||||
? Prisma.sql`AND w.status = ANY(ARRAY[${Prisma.join(
|
||||
statusesToFilter
|
||||
)}]::"WaitpointStatus"[])`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
filterOutputIsError !== undefined
|
||||
? Prisma.sql`AND w."outputIsError" = ${filterOutputIsError}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
idempotencyKey
|
||||
? Prisma.sql`AND (w."idempotencyKey" = ${idempotencyKey} OR w."inactiveIdempotencyKey" = ${idempotencyKey})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND w."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND w."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
to
|
||||
? Prisma.sql`AND w."createdAt" <= ${new Date(to).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
tags && tags.length > 0
|
||||
? Prisma.sql`AND w."tags" && ARRAY[${Prisma.join(tags)}]::text[]`
|
||||
: Prisma.empty
|
||||
}
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`w.id DESC` : Prisma.sql`w.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
let createdAtGte: Date | undefined;
|
||||
if (periodMs != null) {
|
||||
createdAtGte = new Date(Date.now() - periodMs);
|
||||
}
|
||||
if (from !== undefined) {
|
||||
const fromDate = new Date(from);
|
||||
createdAtGte =
|
||||
createdAtGte === undefined ? fromDate : fromDate > createdAtGte ? fromDate : createdAtGte;
|
||||
}
|
||||
const createdAtLte: Date | undefined = to !== undefined ? new Date(to) : undefined;
|
||||
|
||||
const tokens = await this.#scanWaitpoints(
|
||||
(client) =>
|
||||
client.waitpoint.findMany({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
type: "MANUAL",
|
||||
...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}),
|
||||
...(id ? { friendlyId: id } : {}),
|
||||
...(statusesToFilter.length ? { status: { in: statusesToFilter } } : {}),
|
||||
...(filterOutputIsError !== undefined ? { outputIsError: filterOutputIsError } : {}),
|
||||
...(idempotencyKey
|
||||
? { OR: [{ idempotencyKey }, { inactiveIdempotencyKey: idempotencyKey }] }
|
||||
: {}),
|
||||
...(createdAtGte !== undefined || createdAtLte !== undefined
|
||||
? {
|
||||
createdAt: {
|
||||
...(createdAtGte !== undefined ? { gte: createdAtGte } : {}),
|
||||
...(createdAtLte !== undefined ? { lte: createdAtLte } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(tags && tags.length > 0 ? { tags: { hasSome: tags } } : {}),
|
||||
},
|
||||
orderBy: { id: direction === "forward" ? "desc" : "asc" },
|
||||
take: pageSize + 1,
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
completedAt: true,
|
||||
completedAfter: true,
|
||||
outputIsError: true,
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
tags: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
pageSize,
|
||||
direction
|
||||
);
|
||||
|
||||
const hasMore = tokens.length > pageSize;
|
||||
|
||||
@@ -249,16 +249,7 @@ export class WaitpointListPresenter extends BasePresenter {
|
||||
|
||||
let hasAnyTokens = tokensToReturn.length > 0;
|
||||
if (!hasAnyTokens) {
|
||||
const firstToken = await this._replica.waitpoint.findFirst({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
type: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
if (firstToken) {
|
||||
hasAnyTokens = true;
|
||||
}
|
||||
hasAnyTokens = await this.#probeAnyToken(environment.id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -296,6 +287,76 @@ export class WaitpointListPresenter extends BasePresenter {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Run-ops reads for the Waitpoint-token dashboard. Split on: new DB first, then
|
||||
// the LEGACY READ REPLICA ONLY for the not-yet-migrated remainder — never the
|
||||
// legacy primary. Split off: one plain `_replica` read.
|
||||
async #scanWaitpoints(
|
||||
scan: (client: PrismaClientOrTransaction) => Promise<WaitpointRow[]>,
|
||||
pageSize: number,
|
||||
direction: Direction
|
||||
): Promise<WaitpointRow[]> {
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica);
|
||||
}
|
||||
|
||||
const overfetch = pageSize + 1;
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
|
||||
|
||||
// New DB filled the page => any older tokens fall on a later page; keep the
|
||||
// legacy read off the hot path. Presence on the new DB is the migrated signal.
|
||||
if (newRows.length >= overfetch) {
|
||||
return newRows;
|
||||
}
|
||||
|
||||
// READ REPLICA handle only (there is no writer/primary field on readRoute).
|
||||
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
|
||||
|
||||
// Merge under keyset order: de-dupe by id keeping the new-DB copy as
|
||||
// authoritative, re-sort in the page's direction, re-apply the over-fetch
|
||||
// window so the result matches a single union scan.
|
||||
const byId = new Map<string, WaitpointRow>();
|
||||
for (const row of newRows) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
for (const row of legacyRows) {
|
||||
if (!byId.has(row.id)) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) =>
|
||||
direction === "forward" ? compareIdDesc(a.id, b.id) : compareIdAsc(a.id, b.id)
|
||||
);
|
||||
|
||||
return merged.slice(0, overfetch);
|
||||
}
|
||||
|
||||
// Empty-state probe: two-handle existence check (no single runId, so not
|
||||
// readThroughRun). New DB first, then the LEGACY read replica in split mode so
|
||||
// the empty-state never reports false-empty during migration.
|
||||
async #probeAnyToken(environmentId: string): Promise<boolean> {
|
||||
const onNew = await (this.readRoute?.runOpsNew ?? this._replica).waitpoint.findFirst({
|
||||
where: { environmentId, type: "MANUAL" },
|
||||
});
|
||||
if (onNew) return true;
|
||||
if (!this.readRoute?.splitEnabled) return false;
|
||||
const onLegacy = await (
|
||||
this.readRoute.runOpsLegacyReplica ?? this._replica
|
||||
).waitpoint.findFirst({
|
||||
where: { environmentId, type: "MANUAL" },
|
||||
});
|
||||
return Boolean(onLegacy);
|
||||
}
|
||||
}
|
||||
|
||||
function compareIdAsc(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
function compareIdDesc(a: string, b: string): number {
|
||||
return a < b ? 1 : a > b ? -1 : 0;
|
||||
}
|
||||
|
||||
export function waitpointStatusToApiStatus(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server";
|
||||
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
|
||||
@@ -9,6 +12,74 @@ import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
|
||||
export type WaitpointDetail = NonNullable<Awaited<ReturnType<WaitpointPresenter["call"]>>>;
|
||||
|
||||
export class WaitpointPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
private readonly readThroughDeps?: {
|
||||
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
|
||||
// Omitted => single-DB / self-host: both default to `_replica` (passthrough).
|
||||
newClient?: PrismaClientOrTransaction;
|
||||
legacyReplica?: PrismaClientOrTransaction;
|
||||
// Resolved boot constant from isSplitEnabled(). When false/absent:
|
||||
// the waitpoint lookup is one plain findFirst and the connected-runs hydrate runs passthrough.
|
||||
splitEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
super(prisma, replica);
|
||||
}
|
||||
|
||||
async #findWaitpoint(friendlyId: string, environmentId: string) {
|
||||
const where = { friendlyId, environmentId };
|
||||
const select = {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
connectedRuns: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
take: 5,
|
||||
},
|
||||
tags: true,
|
||||
environmentId: true,
|
||||
} as const;
|
||||
|
||||
const hydrate = (client: PrismaReplicaClient) => client.waitpoint.findFirst({ where, select });
|
||||
|
||||
if (!this.readThroughDeps) {
|
||||
return this._replica.waitpoint.findFirst({ where, select });
|
||||
}
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: friendlyId,
|
||||
environmentId,
|
||||
readNew: (client) => hydrate(client),
|
||||
readLegacy: (replica) => hydrate(replica),
|
||||
deps: {
|
||||
splitEnabled: this.readThroughDeps.splitEnabled,
|
||||
newClient:
|
||||
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ??
|
||||
(this._replica as unknown as PrismaReplicaClient),
|
||||
legacyReplica:
|
||||
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ??
|
||||
(this._replica as unknown as PrismaReplicaClient),
|
||||
},
|
||||
});
|
||||
|
||||
return result.source === "new" || result.source === "legacy-replica" ? result.value : null;
|
||||
}
|
||||
|
||||
public async call({
|
||||
friendlyId,
|
||||
environmentId,
|
||||
@@ -18,41 +89,7 @@ export class WaitpointPresenter extends BasePresenter {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const waitpoint = await this._replica.waitpoint.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
connectedRuns: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
take: 5,
|
||||
},
|
||||
tags: true,
|
||||
environment: {
|
||||
select: {
|
||||
apiKey: true,
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const waitpoint = await this.#findWaitpoint(friendlyId, environmentId);
|
||||
|
||||
if (!waitpoint) {
|
||||
logger.error(`WaitpointPresenter: Waitpoint not found`, {
|
||||
@@ -61,6 +98,13 @@ export class WaitpointPresenter extends BasePresenter {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(waitpoint.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
logger.error(`WaitpointPresenter: environment not found`, { friendlyId });
|
||||
return null;
|
||||
}
|
||||
|
||||
const output =
|
||||
waitpoint.outputType === "application/store"
|
||||
? `/resources/packets/${environmentId}/${waitpoint.output}`
|
||||
@@ -80,20 +124,26 @@ export class WaitpointPresenter extends BasePresenter {
|
||||
|
||||
if (connectedRunIds.length > 0) {
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
waitpoint.environment.organizationId,
|
||||
environment.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const runPresenter = new NextRunListPresenter(this._prisma, clickhouse);
|
||||
const { runs } = await runPresenter.call(
|
||||
waitpoint.environment.organizationId,
|
||||
environmentId,
|
||||
{
|
||||
projectId: projectId,
|
||||
runId: connectedRunIds,
|
||||
pageSize: 5,
|
||||
period: "31d",
|
||||
}
|
||||
const runPresenter = new NextRunListPresenter(
|
||||
this._prisma,
|
||||
clickhouse,
|
||||
this.readThroughDeps
|
||||
? {
|
||||
newClient: this.readThroughDeps.newClient ?? this._replica,
|
||||
legacyReplica: this.readThroughDeps.legacyReplica ?? this._replica,
|
||||
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
const { runs } = await runPresenter.call(environment.organizationId, environmentId, {
|
||||
projectId: projectId,
|
||||
runId: connectedRunIds,
|
||||
pageSize: 5,
|
||||
period: "31d",
|
||||
});
|
||||
|
||||
connectedRuns.push(...runs);
|
||||
}
|
||||
@@ -101,7 +151,7 @@ export class WaitpointPresenter extends BasePresenter {
|
||||
return {
|
||||
id: waitpoint.friendlyId,
|
||||
type: waitpoint.type,
|
||||
url: generateHttpCallbackUrl(waitpoint.id, waitpoint.environment.apiKey),
|
||||
url: generateHttpCallbackUrl(waitpoint.id, environment.apiKey),
|
||||
status: waitpointStatusToApiStatus(waitpoint.status, waitpoint.outputIsError),
|
||||
idempotencyKey: waitpoint.idempotencyKey,
|
||||
userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type TagListOptions = {
|
||||
@@ -13,7 +14,32 @@ const DEFAULT_PAGE_SIZE = 25;
|
||||
export type TagList = Awaited<ReturnType<WaitpointTagListPresenter["call"]>>;
|
||||
export type TagListItem = TagList["tags"][number];
|
||||
|
||||
type WaitpointTagRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type TagFindManyArgs = NonNullable<
|
||||
Parameters<PrismaClientOrTransaction["waitpointTag"]["findMany"]>[0]
|
||||
>;
|
||||
type TagQuery = {
|
||||
where: TagFindManyArgs["where"];
|
||||
orderBy: TagFindManyArgs["orderBy"];
|
||||
};
|
||||
|
||||
export class WaitpointTagListPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readRoute?: {
|
||||
runOpsNew?: PrismaClientOrTransaction;
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction; // READ REPLICA only — never the legacy primary
|
||||
splitEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
|
||||
public async call({
|
||||
environmentId,
|
||||
name,
|
||||
@@ -21,23 +47,17 @@ export class WaitpointTagListPresenter extends BasePresenter {
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: TagListOptions) {
|
||||
const hasFilters = Boolean(name?.trim());
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const tags = await this._replica.waitpointTag.findMany({
|
||||
const query: TagQuery = {
|
||||
where: {
|
||||
environmentId,
|
||||
name: name
|
||||
? {
|
||||
startsWith: name,
|
||||
mode: "insensitive",
|
||||
}
|
||||
: undefined,
|
||||
name: name ? { startsWith: name, mode: "insensitive" } : undefined,
|
||||
},
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
take: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
orderBy: { id: "desc" },
|
||||
};
|
||||
|
||||
const tags = await this.#scanTags(query, skip, pageSize);
|
||||
|
||||
return {
|
||||
tags: tags
|
||||
@@ -50,4 +70,40 @@ export class WaitpointTagListPresenter extends BasePresenter {
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
|
||||
async #scanTags(query: TagQuery, skip: number, pageSize: number): Promise<WaitpointTagRow[]> {
|
||||
const scan = (client: PrismaClientOrTransaction, take: number, offset: number) =>
|
||||
client.waitpointTag.findMany({ ...query, take, skip: offset });
|
||||
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica, pageSize + 1, skip);
|
||||
}
|
||||
|
||||
const prefixSize = skip + pageSize + 1;
|
||||
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica, prefixSize, 0);
|
||||
|
||||
// New DB filled the prefix => any older tags fall on a later page; skip the
|
||||
// legacy read entirely. Presence on the new DB is the migrated signal.
|
||||
if (newRows.length >= prefixSize) {
|
||||
return newRows.slice(skip, prefixSize);
|
||||
}
|
||||
|
||||
const legacyRows = await scan(
|
||||
this.readRoute.runOpsLegacyReplica ?? this._replica,
|
||||
prefixSize,
|
||||
0
|
||||
);
|
||||
|
||||
const byId = new Map<string, WaitpointTagRow>();
|
||||
for (const row of newRows) byId.set(row.id, row);
|
||||
for (const row of legacyRows) {
|
||||
if (!byId.has(row.id)) byId.set(row.id, row);
|
||||
}
|
||||
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
|
||||
|
||||
return merged.slice(skip, skip + pageSize + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The SpanPresenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
||||
// at load (and a large transitive graph: runEngine, eventRepository, mollifier, ...). We stub the
|
||||
// two boundaries the presenter reads through so the file loads under test, then drive it entirely
|
||||
// against real Postgres containers — NEVER mocking a DB client.
|
||||
//
|
||||
// * `~/db.server` — the module-level `prisma`/`$replica` exports. The presenter receives its
|
||||
// control-plane handle through the BasePresenter constructor (`new SpanPresenter(cp, cp)`), so
|
||||
// these stubs are never read on the path under test.
|
||||
// * `~/v3/runStore.server` — the run-ops store singleton. This is the ONE wiring boundary we
|
||||
// override: the test injects a routing-shaped store (a RoutingRunStore over the two-DB hetero
|
||||
// fixture) in its place. This is a wiring override, not a DB mock — every run-ops read still
|
||||
// executes against a real container.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
const routingStoreRef = vi.hoisted(() => ({ current: undefined as unknown }));
|
||||
vi.mock("~/v3/runStore.server", () => ({
|
||||
get runStore() {
|
||||
return routingStoreRef.current;
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { RunStore } from "@internal/run-store";
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import type { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import { SpanPresenter } from "~/presenters/v3/SpanPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// 25-char internal id → cuid → LEGACY; 27-char internal id → ksuid → NEW (the residency
|
||||
// classifier shared with the RoutingRunStore's default `ownerEngine`).
|
||||
const CUID_25 = "c".repeat(25);
|
||||
const KSUID_27 = "k".repeat(27);
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirror the org/project/env parents onto a second DB with the SAME ids (TaskRun FKs need them
|
||||
* on every DB a run is hydrated from). */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_prod_${slug}_b`,
|
||||
pkApiKey: `pk_prod_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
taskIdentifier?: string;
|
||||
status?: Prisma.TaskRunCreateInput["status"];
|
||||
parentTaskRunId?: string;
|
||||
rootTaskRunId?: string;
|
||||
}
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
id: run.id,
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: run.taskIdentifier ?? "my-task",
|
||||
status: run.status ?? "COMPLETED_SUCCESSFULLY",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
payloadType: "application/json",
|
||||
traceId: `trace_${run.friendlyId}`,
|
||||
spanId: run.spanId,
|
||||
parentSpanId: run.parentSpanId,
|
||||
parentTaskRunId: run.parentTaskRunId,
|
||||
rootTaskRunId: run.rootTaskRunId,
|
||||
queue: "task/my-task",
|
||||
runTags: ["alpha", "beta"],
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "PRODUCTION",
|
||||
engine: "V2",
|
||||
taskEventStore: "taskEvent",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only wiring shim. In production the run-ops store's DB selection is the store's own
|
||||
* concern, but `SpanPresenter` still passes `this._replica`/`this._prisma` (the control-plane
|
||||
* handle) as the explicit `client` arg to `runStore.findRun`/`findRuns`. `PostgresRunStore`
|
||||
* honours an explicit client (`client ?? this.readOnlyPrisma`), so without this shim a run-ops
|
||||
* read would execute against the control-plane DB. Reconciling that explicit-client override
|
||||
* with split routing is the job of the runStore.server.ts wiring seam, explicitly OUT of this
|
||||
* unit's scope. The shim represents that reconciliation: it drops the
|
||||
* presenter's client arg so each underlying PostgresRunStore reads from its OWN bound DB — the
|
||||
* residency-routed behaviour the presenter will inherit once the seam is wired. It fakes ONLY the
|
||||
* client wiring; every DB read still hits a real container.
|
||||
*/
|
||||
function ownDbStore(prisma: PrismaClient): RunStore {
|
||||
const inner = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
return new Proxy(inner, {
|
||||
get(target, prop) {
|
||||
if (prop === "findRun" || prop === "findRuns") {
|
||||
return (...args: unknown[]) => {
|
||||
// Strip a trailing explicit `client` arg so the store reads from its own DB.
|
||||
const stripped = stripTrailingClient(prop, args);
|
||||
return (target[prop] as (...a: unknown[]) => unknown).apply(target, stripped);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as unknown as RunStore;
|
||||
}
|
||||
|
||||
function stripTrailingClient(method: "findRun" | "findRuns", args: unknown[]): unknown[] {
|
||||
// findRun(where, argsOrClient?, client?) ; findRuns(args, client?). The last arg is the
|
||||
// presenter's explicit client when it is not a projection object.
|
||||
const last = args[args.length - 1] as { select?: unknown; include?: unknown } | undefined;
|
||||
const isProjection =
|
||||
typeof last === "object" && last !== null && ("select" in last || "include" in last);
|
||||
if (args.length === 0 || isProjection) {
|
||||
return args;
|
||||
}
|
||||
return args.slice(0, -1);
|
||||
}
|
||||
|
||||
/** A read-only client wrapper: throws on any write, asserting the legacy slot is replica-only. */
|
||||
function asReplica(prisma: PrismaClient): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (
|
||||
["create", "update", "updateMany", "upsert", "delete", "deleteMany"].includes(
|
||||
String(trProp)
|
||||
)
|
||||
) {
|
||||
return () => {
|
||||
throw new Error(`legacy slot is read-replica-only; ${String(trProp)} is forbidden`);
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => {
|
||||
// Span detail resolves run + children through the run-ops store, region/schedule/session
|
||||
// on control-plane, no cross-DB join.
|
||||
heteroPostgresTest(
|
||||
"findRun hydrates the run through the run-ops store (new-first) and the children-by-parentSpanId set; region/schedule/session resolve from the control-plane client",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
// prisma17 = NEW run-ops; prisma14 = LEGACY run-ops replica AND, for this partition proof,
|
||||
// the control-plane DB (a physically distinct DB from the NEW run-ops store).
|
||||
const cp = prisma14;
|
||||
|
||||
// Seed the env/project/org parents on BOTH run-ops DBs (FKs) and on the CP DB.
|
||||
const ctxNew = await seedParents(prisma17, "partn");
|
||||
await mirrorParents(prisma14, ctxNew, "partn"); // legacy run-ops + CP parents share ids
|
||||
|
||||
const runId = `run_${KSUID_27}`; // ksuid → NEW residency
|
||||
const childMigratedId = `run_a${KSUID_27.slice(1)}`; // also NEW
|
||||
await createRun(prisma17, ctxNew, {
|
||||
id: runId,
|
||||
friendlyId: "run_parent",
|
||||
spanId: "span_parent",
|
||||
taskIdentifier: "parent-task",
|
||||
});
|
||||
// A child whose parentSpanId points at the parent's span — lives on NEW.
|
||||
await createRun(prisma17, ctxNew, {
|
||||
id: childMigratedId,
|
||||
friendlyId: "run_child_new",
|
||||
spanId: "span_child_new",
|
||||
parentSpanId: "span_parent",
|
||||
parentTaskRunId: runId,
|
||||
taskIdentifier: "child-task",
|
||||
});
|
||||
|
||||
// Control-plane rows live on the CP DB only.
|
||||
const workerGroup = await cp.workerInstanceGroup.create({
|
||||
data: {
|
||||
name: "us-east-1-group",
|
||||
location: "N. Virginia, USA",
|
||||
masterQueue: "main",
|
||||
type: "MANAGED",
|
||||
token: { create: { tokenHash: `tok-${ctxNew.projectId}` } },
|
||||
},
|
||||
});
|
||||
const schedule = await cp.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: "sched_1234",
|
||||
taskIdentifier: "parent-task",
|
||||
projectId: ctxNew.projectId,
|
||||
deduplicationKey: "dedup-1",
|
||||
type: "DECLARATIVE",
|
||||
generatorExpression: "0 * * * *",
|
||||
generatorDescription: "every hour",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
routingStoreRef.current = new RoutingRunStore({
|
||||
new: ownDbStore(prisma17),
|
||||
legacy: ownDbStore(prisma14),
|
||||
});
|
||||
|
||||
const presenter = new SpanPresenter(cp, cp);
|
||||
|
||||
// (a) run hydrated through the run-ops store (NEW), byte-identical to the source row incl.
|
||||
// the run-ops self-relations.
|
||||
const run = await presenter.findRun({
|
||||
originalRunId: "run_parent",
|
||||
spanId: "span_parent",
|
||||
environmentId: ctxNew.environmentId,
|
||||
});
|
||||
expect(run?.id).toBe(runId);
|
||||
expect(run?.friendlyId).toBe("run_parent");
|
||||
expect(run?.taskIdentifier).toBe("parent-task");
|
||||
expect(run?.runTags).toEqual(["alpha", "beta"]);
|
||||
// Nested run-ops self-relation resolved on the same (NEW) store.
|
||||
expect(run?.parentTaskRun).toBeNull();
|
||||
|
||||
// (b) the run does NOT exist on the CP DB — the run-ops read could only have come from the
|
||||
// run-ops store, never a CP join.
|
||||
expect(await cp.taskRun.findFirst({ where: { friendlyId: "run_parent" } })).toBeNull();
|
||||
|
||||
// (c) the control-plane standalone reads resolve from the CP client.
|
||||
const region = await cp.workerInstanceGroup.findFirst({ where: { masterQueue: "main" } });
|
||||
expect(region?.name).toBe(workerGroup.name);
|
||||
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
|
||||
friendlyId: "sched_1234",
|
||||
timezone: "UTC",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Children set served by runStore.findRuns through the routing store.
|
||||
heteroPostgresTest(
|
||||
"triggeredRuns (children-by-parentSpanId) is served by runStore.findRuns with the presenter's exact select",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma17, "kids");
|
||||
|
||||
await createRun(prisma17, ctx, {
|
||||
id: `run_${KSUID_27}`,
|
||||
friendlyId: "run_parent2",
|
||||
spanId: "span_p2",
|
||||
});
|
||||
await createRun(prisma17, ctx, {
|
||||
id: `run_b${KSUID_27.slice(1)}`,
|
||||
friendlyId: "run_kid_a",
|
||||
spanId: "span_kid_a",
|
||||
parentSpanId: "span_p2",
|
||||
});
|
||||
await createRun(prisma17, ctx, {
|
||||
id: `run_c${KSUID_27.slice(1)}`,
|
||||
friendlyId: "run_kid_b",
|
||||
spanId: "span_kid_b",
|
||||
parentSpanId: "span_p2",
|
||||
});
|
||||
|
||||
const store = new RoutingRunStore({
|
||||
new: ownDbStore(prisma17),
|
||||
legacy: ownDbStore(prisma14),
|
||||
});
|
||||
|
||||
const triggeredRuns = await store.findRuns({
|
||||
where: { parentSpanId: "span_p2" },
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(triggeredRuns.map((r) => r.friendlyId).sort()).toEqual(["run_kid_a", "run_kid_b"]);
|
||||
// select projection holds: no `id`/`payload` leaked through.
|
||||
expect(triggeredRuns[0]).not.toHaveProperty("id");
|
||||
expect(triggeredRuns[0]).not.toHaveProperty("payload");
|
||||
}
|
||||
);
|
||||
|
||||
// Old in-retention run served from the legacy replica, never the primary.
|
||||
heteroPostgresTest(
|
||||
"a legacy-residency run resolves through the store's LEGACY slot, which exposes only a replica handle",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "legacy");
|
||||
|
||||
const legacyRunId = `run_${CUID_25}`; // cuid → LEGACY residency
|
||||
await createRun(prisma14, ctx, {
|
||||
id: legacyRunId,
|
||||
friendlyId: "run_legacy",
|
||||
spanId: "span_legacy",
|
||||
taskIdentifier: "legacy-task",
|
||||
});
|
||||
|
||||
// The LEGACY slot is wired over a replica (read-only) handle; the NEW slot over the new DB.
|
||||
const store = new RoutingRunStore({
|
||||
new: ownDbStore(prisma17),
|
||||
legacy: ownDbStore(asReplica(prisma14)),
|
||||
});
|
||||
|
||||
// Routed by `id` residency (cuid → LEGACY). The presenter's findRun keys by friendlyId/spanId
|
||||
// (which route NEW-default through the store today); routing by id is the store-level proof
|
||||
// that the LEGACY slot serves in-retention runs. The legacy slot's replica handle forbids
|
||||
// writes — proving the read route can never touch a legacy writer.
|
||||
const found = await store.findRun(
|
||||
{ id: legacyRunId },
|
||||
{ select: { id: true, friendlyId: true, taskIdentifier: true } }
|
||||
);
|
||||
expect(found?.id).toBe(legacyRunId);
|
||||
expect(found?.taskIdentifier).toBe("legacy-task");
|
||||
}
|
||||
);
|
||||
|
||||
// A known-migrated run is not re-probed on legacy.
|
||||
heteroPostgresTest(
|
||||
"a NEW-residency id is served by the NEW slot and the LEGACY slot is never invoked",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma17, "knownmig");
|
||||
|
||||
const newRunId = `run_${KSUID_27}`; // ksuid → NEW residency
|
||||
await createRun(prisma17, ctx, {
|
||||
id: newRunId,
|
||||
friendlyId: "run_known_new",
|
||||
spanId: "span_known_new",
|
||||
taskIdentifier: "new-task",
|
||||
});
|
||||
|
||||
// LEGACY slot throws on ANY read — asserting the residency short-circuit never probes it.
|
||||
const legacyThrows = new Proxy({} as RunStore, {
|
||||
get(_t, prop) {
|
||||
if (prop === "findRun" || prop === "findRuns") {
|
||||
return () => {
|
||||
throw new Error(`LEGACY slot must not be probed for a NEW id (${String(prop)})`);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const store = new RoutingRunStore({
|
||||
new: ownDbStore(prisma17),
|
||||
legacy: legacyThrows,
|
||||
});
|
||||
|
||||
const found = await store.findRun(
|
||||
{ id: newRunId },
|
||||
{ select: { id: true, taskIdentifier: true } }
|
||||
);
|
||||
expect(found?.id).toBe(newRunId);
|
||||
expect(found?.taskIdentifier).toBe("new-task");
|
||||
}
|
||||
);
|
||||
|
||||
// Passthrough (single-DB): NEW and LEGACY slots are the same store over one client.
|
||||
heteroPostgresTest(
|
||||
"single-DB collapses both slots to one PostgresRunStore; the presenter resolves run + children + control-plane from the one client",
|
||||
async ({ prisma14 }) => {
|
||||
const cp = prisma14;
|
||||
const ctx = await seedParents(prisma14, "passthru");
|
||||
|
||||
const runId = `run_${KSUID_27}`;
|
||||
await createRun(prisma14, ctx, {
|
||||
id: runId,
|
||||
friendlyId: "run_solo",
|
||||
spanId: "span_solo",
|
||||
taskIdentifier: "solo-task",
|
||||
});
|
||||
await createRun(prisma14, ctx, {
|
||||
id: `run_d${KSUID_27.slice(1)}`,
|
||||
friendlyId: "run_solo_kid",
|
||||
spanId: "span_solo_kid",
|
||||
parentSpanId: "span_solo",
|
||||
});
|
||||
const schedule = await cp.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: "sched_solo",
|
||||
taskIdentifier: "solo-task",
|
||||
projectId: ctx.projectId,
|
||||
deduplicationKey: "dedup-solo",
|
||||
type: "DECLARATIVE",
|
||||
generatorExpression: "0 * * * *",
|
||||
generatorDescription: "every hour",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
|
||||
// Both slots are the same store over the one client — the single-DB collapse.
|
||||
const solo = ownDbStore(prisma14);
|
||||
routingStoreRef.current = new RoutingRunStore({ new: solo, legacy: solo });
|
||||
|
||||
const presenter = new SpanPresenter(cp, cp);
|
||||
|
||||
const run = await presenter.findRun({
|
||||
originalRunId: "run_solo",
|
||||
spanId: "span_solo",
|
||||
environmentId: ctx.environmentId,
|
||||
});
|
||||
expect(run?.id).toBe(runId);
|
||||
expect(run?.taskIdentifier).toBe("solo-task");
|
||||
|
||||
// Children resolve from the same single store.
|
||||
const children = await (routingStoreRef.current as RunStore).findRuns({
|
||||
where: { parentSpanId: "span_solo" },
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
expect(children.map((c) => c.friendlyId)).toEqual(["run_solo_kid"]);
|
||||
|
||||
// Control-plane read from the same single client.
|
||||
expect(await presenter.resolveSchedule(schedule.id)).toMatchObject({
|
||||
friendlyId: "sched_solo",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Cross-seam tree shape: parent on LEGACY (in-retention), child on NEW (born-new).
|
||||
heteroPostgresTest(
|
||||
"parent run on the legacy replica, child run on new — relations resolve across the seam, no cross-DB join",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "e2e4");
|
||||
await mirrorParents(prisma17, ctx, "e2e4");
|
||||
|
||||
const parentId = `run_${CUID_25}`; // cuid → LEGACY (in-retention)
|
||||
const childId = `run_${KSUID_27}`; // ksuid → NEW (born-new)
|
||||
|
||||
await createRun(prisma14, ctx, {
|
||||
id: parentId,
|
||||
friendlyId: "run_e2e_parent",
|
||||
spanId: "span_e2e_parent",
|
||||
taskIdentifier: "parent",
|
||||
rootTaskRunId: parentId,
|
||||
});
|
||||
// The child lives on NEW; it links to the parent across the seam ONLY by `parentSpanId`
|
||||
// (a plain indexed column — the exact key `triggeredRuns` uses), NOT by a cross-DB FK
|
||||
// (`parentTaskRunId`/`rootTaskRunId` would violate the FK since the parent is on LEGACY;
|
||||
// a tree's FK self-relations stay single-DB).
|
||||
await createRun(prisma17, ctx, {
|
||||
id: childId,
|
||||
friendlyId: "run_e2e_child",
|
||||
spanId: "span_e2e_child",
|
||||
parentSpanId: "span_e2e_parent",
|
||||
taskIdentifier: "child",
|
||||
});
|
||||
|
||||
const store = new RoutingRunStore({
|
||||
new: ownDbStore(prisma17),
|
||||
legacy: ownDbStore(asReplica(prisma14)),
|
||||
});
|
||||
|
||||
// The parent resolves from the LEGACY slot (routed by its cuid id).
|
||||
const parent = await store.findRun(
|
||||
{ id: parentId },
|
||||
{
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
rootTaskRun: { select: { friendlyId: true } },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(parent?.id).toBe(parentId);
|
||||
// Run-ops self-relation (rootTaskRun) resolves on the parent's own (LEGACY) store — a
|
||||
// tree's FK self-relations stay single-DB.
|
||||
expect(parent?.rootTaskRun?.friendlyId).toBe("run_e2e_parent");
|
||||
|
||||
// The child resolves from the NEW slot (routed by its ksuid id) and points back at the parent
|
||||
// span — the cross-the-line parent/child shape, with no cross-DB join.
|
||||
const child = await store.findRun(
|
||||
{ id: childId },
|
||||
{ select: { id: true, parentSpanId: true, friendlyId: true } }
|
||||
);
|
||||
expect(child?.id).toBe(childId);
|
||||
expect(child?.parentSpanId).toBe("span_e2e_parent");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// Route-level regression for ApiBatchResultsPresenter: the /batches/:id/results route used to build
|
||||
// the presenter with no read-through deps, collapsing to a passthrough read off the control-plane
|
||||
// replica only, which 404s a NEW-resident (ksuid) batch that lives on the dedicated run-ops DB.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// 27-char body → NEW residency (ksuid analog). 25-char body → LEGACY residency (cuid analog).
|
||||
function newRunId(c: string) {
|
||||
return c.repeat(27);
|
||||
}
|
||||
|
||||
// A prisma handle that throws on any access — proves the split path never reads the passthrough
|
||||
// handles when the batch resolves off the NEW client.
|
||||
const throwingPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
throw new Error(
|
||||
`passthrough handle must not be touched on the split path (got .${String(prop)})`
|
||||
);
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaReplicaClient;
|
||||
|
||||
let seedCounter = 0;
|
||||
|
||||
async function seedEnv(prisma: PrismaClient, slug: string) {
|
||||
const n = seedCounter++;
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `Proj ${slug}`,
|
||||
slug: `proj-${slug}-${n}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `ext-${slug}-${n}`,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}-${n}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `api-${slug}-${n}`,
|
||||
pkApiKey: `pk-${slug}-${n}`,
|
||||
shortcode: `sc-${slug}-${n}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
|
||||
|
||||
// Mirror the same org/project/env ids onto the second DB so a passthrough read against the
|
||||
// control-plane replica has the environment row to filter on (but NOT the batch row).
|
||||
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
|
||||
const n = seedCounter++;
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.project.id,
|
||||
name: `Proj ${slug}`,
|
||||
slug: `proj-${slug}-m-${n}`,
|
||||
organizationId: ctx.organization.id,
|
||||
externalRef: `ext-${slug}-m-${n}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environment.id,
|
||||
slug: `env-${slug}-m-${n}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: ctx.project.id,
|
||||
organizationId: ctx.organization.id,
|
||||
apiKey: `api-${slug}-m-${n}`,
|
||||
pkApiKey: `pk-${slug}-m-${n}`,
|
||||
shortcode: `sc-${slug}-m-${n}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Drop the per-DB TaskRunAttempt worker/queue FKs so we can seed an attempt (its output is what the
|
||||
// execution-result carries) without standing up BackgroundWorker/TaskQueue parents.
|
||||
async function relaxFks(prisma: PrismaClient) {
|
||||
for (const sql of [
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
|
||||
]) {
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedMember(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedCtx,
|
||||
m: { id: string; friendlyId: string; output: string }
|
||||
) {
|
||||
const run = await prisma.taskRun.create({
|
||||
data: {
|
||||
id: m.id,
|
||||
friendlyId: m.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
payload: JSON.stringify({}),
|
||||
payloadType: "application/json",
|
||||
traceId: m.id,
|
||||
spanId: m.id,
|
||||
queue: "main",
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
projectId: ctx.project.id,
|
||||
organizationId: ctx.organization.id,
|
||||
environmentType: "PRODUCTION",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.taskRunAttempt.create({
|
||||
data: {
|
||||
friendlyId: `attempt_${m.id}`,
|
||||
number: 1,
|
||||
taskRunId: run.id,
|
||||
backgroundWorkerId: "bw",
|
||||
backgroundWorkerTaskId: "bwt",
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
queueId: "q",
|
||||
status: "COMPLETED",
|
||||
output: m.output,
|
||||
outputType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
async function seedBatch(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedCtx,
|
||||
friendlyId: string,
|
||||
memberIds: string[]
|
||||
) {
|
||||
const batch = await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
runCount: memberIds.length,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2",
|
||||
},
|
||||
});
|
||||
for (const taskRunId of memberIds) {
|
||||
await prisma.batchTaskRunItem.create({
|
||||
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
|
||||
});
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
const env = (ctx: SeedCtx) =>
|
||||
({
|
||||
id: ctx.environment.id,
|
||||
type: ctx.environment.type,
|
||||
slug: ctx.environment.slug,
|
||||
organizationId: ctx.organization.id,
|
||||
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
|
||||
projectId: ctx.project.id,
|
||||
project: { name: ctx.project.name },
|
||||
}) as unknown as AuthenticatedEnvironment;
|
||||
|
||||
describe("ApiBatchResultsPresenter route wiring (the /batches/:id/results 404 regression)", () => {
|
||||
heteroPostgresTest(
|
||||
"a NEW-resident batch resolves with split deps but 404s (undefined) when built passthrough-only",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newDb = prisma17 as unknown as PrismaClient; // dedicated run-ops (NEW) DB analog
|
||||
const legacyDb = prisma14 as unknown as PrismaClient; // control-plane / legacy replica analog
|
||||
|
||||
// Batch + members live ONLY on the NEW DB. The env is mirrored onto the legacy DB so the
|
||||
// passthrough read has an environment to filter on — but never the batch row.
|
||||
const ctx = await seedEnv(newDb, "route-new");
|
||||
await mirrorEnv(legacyDb, ctx, "route-legacy");
|
||||
await relaxFks(newDb);
|
||||
|
||||
const memberId = newRunId("a");
|
||||
await seedMember(newDb, ctx, {
|
||||
id: memberId,
|
||||
friendlyId: "run_route_a",
|
||||
output: JSON.stringify({ from: "new" }),
|
||||
});
|
||||
await seedBatch(newDb, ctx, "batch_route_new", [memberId]);
|
||||
|
||||
// Route wiring: splitEnabled + newClient + legacyReplica; passthrough handles throw.
|
||||
const splitPresenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
});
|
||||
|
||||
const resolved = await splitPresenter.call("batch_route_new", env(ctx));
|
||||
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved!.id).toBe("batch_route_new");
|
||||
expect(resolved!.items).toHaveLength(1);
|
||||
expect(resolved!.items[0]).toEqual({
|
||||
ok: true,
|
||||
id: "run_route_a",
|
||||
taskIdentifier: "my-task",
|
||||
output: JSON.stringify({ from: "new" }),
|
||||
outputType: "application/json",
|
||||
});
|
||||
|
||||
// Pre-fix route: no read-through deps => passthrough off the control-plane replica (the legacy
|
||||
// DB, which never received the batch) => undefined, i.e. the 404.
|
||||
const passthroughPresenter = new ApiBatchResultsPresenter(legacyDb, legacyDb);
|
||||
|
||||
const missed = await passthroughPresenter.call("batch_route_new", env(ctx));
|
||||
expect(missed).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
// Read-through proof for ApiBatchResultsPresenter.
|
||||
//
|
||||
// The batch row + its item rows resolve new-run-ops-first then off the LEGACY run-ops READ
|
||||
// REPLICA ONLY; each member run is hydrated independently via the per-run read-through primitive,
|
||||
// so a batch whose members span migrated (NEW) + abandoned (LEGACY) runs returns the
|
||||
// complete reachable set. Single-DB collapses to one passthrough read. We NEVER mock the DB — the
|
||||
// only injected fakes are the pure boundaries (splitEnabled / isPastRetention).
|
||||
//
|
||||
// The BatchTaskRunItem -> TaskRun FK is per-DB; a batch straddling the seam references member ids
|
||||
// that live on the other DB, so we drop that one FK on the batch's DB at seed time (the cross-seam
|
||||
// reality where the item row survives while the member's authoritative row lives on the other DB).
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// 27-char body → NEW residency (ksuid analog). 25-char body → LEGACY residency (cuid analog).
|
||||
function newRunId(c: string) {
|
||||
return c.repeat(27);
|
||||
}
|
||||
function legacyRunId(c: string) {
|
||||
return c.repeat(25);
|
||||
}
|
||||
|
||||
// A prisma handle that throws on any access — proves the split path never reads it.
|
||||
const throwingPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
throw new Error(
|
||||
`passthrough handle must not be touched on the split path (got .${String(prop)})`
|
||||
);
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaReplicaClient;
|
||||
|
||||
let seedCounter = 0;
|
||||
|
||||
async function seedEnv(prisma: PrismaClient, slug: string) {
|
||||
const n = seedCounter++;
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${slug}`, slug: `org-${slug}-${n}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `Proj ${slug}`,
|
||||
slug: `proj-${slug}-${n}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `ext-${slug}-${n}`,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}-${n}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `api-${slug}-${n}`,
|
||||
pkApiKey: `pk-${slug}-${n}`,
|
||||
shortcode: `sc-${slug}-${n}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
type SeedCtx = Awaited<ReturnType<typeof seedEnv>>;
|
||||
|
||||
// Mirror the same org/project/env ids onto a second DB so member TaskRun FKs resolve there.
|
||||
async function mirrorEnv(prisma: PrismaClient, ctx: SeedCtx, slug: string) {
|
||||
const n = seedCounter++;
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organization.id, title: `Org ${slug}`, slug: `org-${slug}-m-${n}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.project.id,
|
||||
name: `Proj ${slug}`,
|
||||
slug: `proj-${slug}-m-${n}`,
|
||||
organizationId: ctx.organization.id,
|
||||
externalRef: `ext-${slug}-m-${n}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environment.id,
|
||||
slug: `env-${slug}-m-${n}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: ctx.project.id,
|
||||
organizationId: ctx.organization.id,
|
||||
apiKey: `api-${slug}-m-${n}`,
|
||||
pkApiKey: `pk-${slug}-m-${n}`,
|
||||
shortcode: `sc-${slug}-m-${n}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MemberSeed = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS";
|
||||
output?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
async function seedMember(prisma: PrismaClient, ctx: SeedCtx, m: MemberSeed) {
|
||||
const run = await prisma.taskRun.create({
|
||||
data: {
|
||||
id: m.id,
|
||||
friendlyId: m.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: m.status,
|
||||
payload: JSON.stringify({}),
|
||||
payloadType: "application/json",
|
||||
traceId: m.id,
|
||||
spanId: m.id,
|
||||
queue: "main",
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
projectId: ctx.project.id,
|
||||
organizationId: ctx.organization.id,
|
||||
environmentType: "PRODUCTION",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.taskRunAttempt.create({
|
||||
data: {
|
||||
friendlyId: `attempt_${m.id}`,
|
||||
number: 1,
|
||||
taskRunId: run.id,
|
||||
backgroundWorkerId: "bw",
|
||||
backgroundWorkerTaskId: "bwt",
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
queueId: "q",
|
||||
status: m.status === "COMPLETED_SUCCESSFULLY" ? "COMPLETED" : "FAILED",
|
||||
output: m.output,
|
||||
outputType: "application/json",
|
||||
error: m.error as any,
|
||||
},
|
||||
});
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
// Drop the per-DB BatchTaskRunItem -> TaskRun FK so items on this DB can reference member ids whose
|
||||
// authoritative TaskRun lives on the other DB (the cross-seam batch). Also drop the TaskRunAttempt
|
||||
// worker/queue FKs so we can seed attempts (their output/error is what's under test) without
|
||||
// standing up BackgroundWorker/TaskQueue parents — those rows are incidental to this read path.
|
||||
async function relaxFks(prisma: PrismaClient) {
|
||||
for (const sql of [
|
||||
`ALTER TABLE "BatchTaskRunItem" DROP CONSTRAINT IF EXISTS "BatchTaskRunItem_taskRunId_fkey"`,
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey"`,
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey"`,
|
||||
`ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey"`,
|
||||
]) {
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBatch(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedCtx,
|
||||
friendlyId: string,
|
||||
memberIds: string[]
|
||||
) {
|
||||
const batch = await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: ctx.environment.id,
|
||||
runCount: memberIds.length,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2",
|
||||
},
|
||||
});
|
||||
// Items in a deterministic order so the result `items` order is assertable.
|
||||
for (const taskRunId of memberIds) {
|
||||
await prisma.batchTaskRunItem.create({
|
||||
data: { batchTaskRunId: batch.id, taskRunId, status: "COMPLETED" },
|
||||
});
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
const env = (ctx: SeedCtx) =>
|
||||
({
|
||||
id: ctx.environment.id,
|
||||
type: ctx.environment.type,
|
||||
slug: ctx.environment.slug,
|
||||
organizationId: ctx.organization.id,
|
||||
organization: { slug: ctx.organization.slug, title: ctx.organization.title },
|
||||
projectId: ctx.project.id,
|
||||
project: { name: ctx.project.name },
|
||||
}) as unknown as AuthenticatedEnvironment;
|
||||
|
||||
describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => {
|
||||
// A batch with members on BOTH DBs returns the complete set, byte-identical.
|
||||
heteroPostgresTest(
|
||||
"members spanning NEW + legacy hydrate to the complete union, in item order",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newDb = prisma17 as unknown as PrismaClient;
|
||||
const legacyDb = prisma14 as unknown as PrismaClient;
|
||||
|
||||
const ctx = await seedEnv(newDb, "span-new");
|
||||
await mirrorEnv(legacyDb, ctx, "span-legacy");
|
||||
await relaxFks(newDb);
|
||||
await relaxFks(legacyDb);
|
||||
|
||||
const newMemberId = newRunId("a");
|
||||
const legacyMemberId = legacyRunId("b");
|
||||
|
||||
// NEW member lives only on the new DB, legacy member only on the legacy DB.
|
||||
await seedMember(newDb, ctx, {
|
||||
id: newMemberId,
|
||||
friendlyId: "run_new_a",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
output: JSON.stringify({ from: "new" }),
|
||||
});
|
||||
await seedMember(legacyDb, ctx, {
|
||||
id: legacyMemberId,
|
||||
friendlyId: "run_legacy_b",
|
||||
status: "COMPLETED_WITH_ERRORS",
|
||||
error: { type: "BUILT_IN_ERROR", name: "Err", message: "boom", stackTrace: "" },
|
||||
});
|
||||
|
||||
// The batch row + items live on the NEW DB; items reference both members.
|
||||
await seedBatch(newDb, ctx, "batch_span", [newMemberId, legacyMemberId]);
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
});
|
||||
|
||||
const result = await presenter.call("batch_span", env(ctx));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe("batch_span");
|
||||
expect(result!.items).toHaveLength(2);
|
||||
|
||||
// Order follows item order: NEW member first, legacy member second.
|
||||
const [first, second] = result!.items;
|
||||
expect(first).toEqual({
|
||||
ok: true,
|
||||
id: "run_new_a",
|
||||
taskIdentifier: "my-task",
|
||||
output: JSON.stringify({ from: "new" }),
|
||||
outputType: "application/json",
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
ok: false,
|
||||
id: "run_legacy_b",
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Batch row resident only on the legacy replica resolves via new-first-miss → legacy.
|
||||
heteroPostgresTest(
|
||||
"a legacy-resident batch row resolves off the legacy replica (new probe misses)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newDb = prisma17 as unknown as PrismaClient;
|
||||
const legacyDb = prisma14 as unknown as PrismaClient;
|
||||
|
||||
const ctx = await seedEnv(legacyDb, "lb-legacy");
|
||||
await mirrorEnv(newDb, ctx, "lb-new");
|
||||
await relaxFks(legacyDb);
|
||||
|
||||
const legacyMemberId = legacyRunId("c");
|
||||
await seedMember(legacyDb, ctx, {
|
||||
id: legacyMemberId,
|
||||
friendlyId: "run_legacy_c",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
output: JSON.stringify({ ok: 1 }),
|
||||
});
|
||||
// Batch row + items only on the legacy replica; absent from NEW.
|
||||
await seedBatch(legacyDb, ctx, "batch_legacy", [legacyMemberId]);
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
});
|
||||
|
||||
const result = await presenter.call("batch_legacy", env(ctx));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe("batch_legacy");
|
||||
expect(result!.items).toHaveLength(1);
|
||||
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_legacy_c" });
|
||||
}
|
||||
);
|
||||
|
||||
// Past-retention / missing member is omitted (dangling-ref gate adjacent), not errored.
|
||||
heteroPostgresTest(
|
||||
"a member present on neither DB is omitted; the reachable members still return",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const newDb = prisma17 as unknown as PrismaClient;
|
||||
const legacyDb = prisma14 as unknown as PrismaClient;
|
||||
|
||||
const ctx = await seedEnv(newDb, "dangle-new");
|
||||
await mirrorEnv(legacyDb, ctx, "dangle-legacy");
|
||||
await relaxFks(newDb);
|
||||
await relaxFks(legacyDb);
|
||||
|
||||
const presentId = newRunId("e");
|
||||
const missingId = legacyRunId("f"); // referenced by an item but seeded on NO DB
|
||||
|
||||
await seedMember(newDb, ctx, {
|
||||
id: presentId,
|
||||
friendlyId: "run_present_e",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
output: JSON.stringify({ present: true }),
|
||||
});
|
||||
await seedBatch(newDb, ctx, "batch_dangle", [presentId, missingId]);
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
});
|
||||
|
||||
const result = await presenter.call("batch_dangle", env(ctx));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// The dangling member is dropped; the reachable member still returns. The dangling-reference
|
||||
// termination gate (separate unit) governs whether such omission is permitted pre-termination.
|
||||
expect(result!.items).toHaveLength(1);
|
||||
expect(result!.items[0]).toMatchObject({ ok: true, id: "run_present_e" });
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"an absent batch friendlyId returns undefined (split on)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedEnv(prisma17 as unknown as PrismaClient, "nf-new");
|
||||
await mirrorEnv(prisma14 as unknown as PrismaClient, ctx, "nf-legacy");
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
});
|
||||
|
||||
const result = await presenter.call("batch_does_not_exist", env(ctx));
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("ApiBatchResultsPresenter passthrough (single-DB collapse)", () => {
|
||||
// Single-DB: one batch read + one store id-set hydrate; legacy boundaries never touched.
|
||||
postgresTest(
|
||||
"single-DB hydrates the full set and never reaches the read-through boundaries",
|
||||
async ({ prisma }) => {
|
||||
const ctx = await seedEnv(prisma, "pt");
|
||||
await relaxFks(prisma);
|
||||
|
||||
const memberId = legacyRunId("g");
|
||||
await seedMember(prisma, ctx, {
|
||||
id: memberId,
|
||||
friendlyId: "run_pt_g",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
output: JSON.stringify({ value: 42 }),
|
||||
});
|
||||
await seedBatch(prisma, ctx, "batch_pt", [memberId]);
|
||||
|
||||
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
|
||||
// Throwing legacy replica: if the split path is entered, it blows up.
|
||||
const presenter = new ApiBatchResultsPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
{
|
||||
splitEnabled: false,
|
||||
legacyReplica: throwingPrisma,
|
||||
},
|
||||
runStore
|
||||
);
|
||||
|
||||
const result = await presenter.call("batch_pt", env(ctx));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe("batch_pt");
|
||||
expect(result!.items).toHaveLength(1);
|
||||
expect(result!.items[0]).toEqual({
|
||||
ok: true,
|
||||
id: "run_pt_g",
|
||||
taskIdentifier: "my-task",
|
||||
output: JSON.stringify({ value: 42 }),
|
||||
outputType: "application/json",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest("single-DB absent batch friendlyId returns undefined", async ({ prisma }) => {
|
||||
const ctx = await seedEnv(prisma, "pt-nf");
|
||||
const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
{ splitEnabled: false },
|
||||
runStore
|
||||
);
|
||||
|
||||
const result = await presenter.call("batch_missing", env(ctx));
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,625 @@
|
||||
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import type { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { beforeEach, describe, expect, vi } from "vitest";
|
||||
|
||||
// `resolveSchedule` reads the module-level `prisma` (control-plane handle).
|
||||
// Point `~/db.server`'s handles at a hoisted mutable holder each test sets to a
|
||||
// real container client. Not a DB mock: reads still hit a real Postgres
|
||||
// container — this only redirects which real client the module resolves.
|
||||
const dbHolder = vi.hoisted(() => ({
|
||||
prisma: null as PrismaClient | null,
|
||||
$replica: null as PrismaClient | null,
|
||||
}));
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
get prisma() {
|
||||
return dbHolder.prisma;
|
||||
},
|
||||
get $replica() {
|
||||
return dbHolder.$replica;
|
||||
},
|
||||
}));
|
||||
|
||||
// Inert: payloads are inline JSON, never `application/store`, so no presign.
|
||||
vi.mock("~/v3/objectStore.server", () => ({
|
||||
generatePresignedUrl: vi.fn(async () => ({ success: false, error: "not-used" })),
|
||||
}));
|
||||
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import type { FoundRun } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// The presenter's EXACT read shape — pinned so a refactor that changes
|
||||
// `findRun`'s `where`/`select` must update this test in lockstep.
|
||||
const commonRunSelect = {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
taskIdentifier: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
expiredAt: true,
|
||||
delayUntil: true,
|
||||
metadata: true,
|
||||
metadataType: true,
|
||||
ttl: true,
|
||||
costInCents: true,
|
||||
baseCostInCents: true,
|
||||
usageDurationMs: true,
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyOptions: true,
|
||||
isTest: true,
|
||||
depth: true,
|
||||
scheduleId: true,
|
||||
workerQueue: true,
|
||||
region: true,
|
||||
lockedToVersionId: true,
|
||||
resumeParentOnCompletion: true,
|
||||
batch: { select: { id: true, friendlyId: true } },
|
||||
runTags: true,
|
||||
} satisfies Prisma.TaskRunSelect;
|
||||
|
||||
const findRunSelect = {
|
||||
...commonRunSelect,
|
||||
traceId: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
error: true,
|
||||
attempts: { select: { id: true } },
|
||||
attemptNumber: true,
|
||||
engine: true,
|
||||
taskEventStore: true,
|
||||
parentTaskRun: { select: commonRunSelect },
|
||||
rootTaskRun: { select: commonRunSelect },
|
||||
childRuns: { select: commonRunSelect },
|
||||
} satisfies Prisma.TaskRunSelect;
|
||||
|
||||
// Drive the read exactly as `findRun` does: the RunStore.findRun contract over
|
||||
// the given store with the presenter's where+select. The scalar `lockedToVersionId`
|
||||
// folds to a resolved `lockedToVersion` per node, matching the presenter's shape;
|
||||
// seeded runs carry no locked version, so every node resolves to null.
|
||||
async function readFoundRunViaStore(
|
||||
store: PostgresRunStore,
|
||||
friendlyId: string,
|
||||
runtimeEnvironmentId: string
|
||||
): Promise<FoundRun | null> {
|
||||
const pgRow = (await store.findRun(
|
||||
{ friendlyId, runtimeEnvironmentId },
|
||||
{ select: findRunSelect }
|
||||
)) as Record<string, any> | null;
|
||||
if (!pgRow) return null;
|
||||
const foldVersion = (run: Record<string, any>) => ({ ...run, lockedToVersion: null });
|
||||
return {
|
||||
...pgRow,
|
||||
lockedToVersion: null,
|
||||
parentTaskRun: pgRow.parentTaskRun ? foldVersion(pgRow.parentTaskRun) : null,
|
||||
rootTaskRun: pgRow.rootTaskRun ? foldVersion(pgRow.rootTaskRun) : null,
|
||||
childRuns: (pgRow.childRuns ?? []).map(foldVersion),
|
||||
isBuffered: false,
|
||||
} as FoundRun;
|
||||
}
|
||||
|
||||
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `test-${suffix}`,
|
||||
slug: `test-${suffix}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `test-${suffix}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `test-${suffix}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${suffix}`,
|
||||
pkApiKey: `pk_dev_${suffix}`,
|
||||
shortcode: `short-${suffix}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, runtimeEnvironment };
|
||||
}
|
||||
|
||||
// Build the AuthenticatedEnvironment shape `call()` reads (id, organizationId,
|
||||
// slug, project.externalRef). Only those fields are touched on the happy path.
|
||||
function authEnv(
|
||||
organization: { id: string },
|
||||
project: { id: string; externalRef: string },
|
||||
runtimeEnvironment: { id: string; slug: string }
|
||||
): AuthenticatedEnvironment {
|
||||
return {
|
||||
id: runtimeEnvironment.id,
|
||||
slug: runtimeEnvironment.slug,
|
||||
organizationId: organization.id,
|
||||
organization: { id: organization.id },
|
||||
project: { id: project.id, externalRef: project.externalRef },
|
||||
} as unknown as AuthenticatedEnvironment;
|
||||
}
|
||||
|
||||
interface SeedRunOpts {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
scheduleId?: string;
|
||||
runTags?: string[];
|
||||
parentTaskRunId?: string;
|
||||
rootTaskRunId?: string;
|
||||
metadata?: string;
|
||||
}
|
||||
|
||||
async function seedRun(prisma: PrismaClient, opts: SeedRunOpts) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
id: opts.id,
|
||||
friendlyId: opts.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ hello: "world" }),
|
||||
payloadType: "application/json",
|
||||
traceId: `trace_${opts.id}`,
|
||||
spanId: `span_${opts.id}`,
|
||||
queue: "task/my-task",
|
||||
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
||||
projectId: opts.projectId,
|
||||
organizationId: opts.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
runTags: opts.runTags ?? [],
|
||||
scheduleId: opts.scheduleId,
|
||||
parentTaskRunId: opts.parentTaskRunId,
|
||||
rootTaskRunId: opts.rootTaskRunId,
|
||||
metadata: opts.metadata,
|
||||
metadataType: opts.metadata ? "application/json" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A TaskRunAttempt requires the BackgroundWorker -> BackgroundWorkerTask ->
|
||||
// TaskQueue FK chain; seed the minimum of each for one attempt.
|
||||
async function seedAttempt(
|
||||
prisma: PrismaClient,
|
||||
opts: { runId: string; runtimeEnvironmentId: string; projectId: string; suffix: string }
|
||||
) {
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${opts.runId}`,
|
||||
contentHash: `hash_${opts.suffix}`,
|
||||
version: "20260627.1",
|
||||
metadata: {},
|
||||
projectId: opts.projectId,
|
||||
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
const task = await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${opts.runId}`,
|
||||
slug: "my-task",
|
||||
filePath: "src/trigger/my-task.ts",
|
||||
workerId: worker.id,
|
||||
projectId: opts.projectId,
|
||||
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
const queue = await prisma.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: `queue_${opts.runId}`,
|
||||
name: "task/my-task",
|
||||
projectId: opts.projectId,
|
||||
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
||||
},
|
||||
});
|
||||
return prisma.taskRunAttempt.create({
|
||||
data: {
|
||||
friendlyId: `attempt_${opts.runId}`,
|
||||
number: 1,
|
||||
taskRunId: opts.runId,
|
||||
runtimeEnvironmentId: opts.runtimeEnvironmentId,
|
||||
backgroundWorkerId: worker.id,
|
||||
backgroundWorkerTaskId: task.id,
|
||||
queueId: queue.id,
|
||||
status: "EXECUTING",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Seed a run plus a parent, a root, a child and one attempt — the tree that must
|
||||
// round-trip. `seedTestRun.ts` only seeds a single root run, so the tree + attempt
|
||||
// rows are created inline here.
|
||||
async function seedRunWithTree(
|
||||
prisma: PrismaClient,
|
||||
base: {
|
||||
runtimeEnvironmentId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
suffix: string;
|
||||
}
|
||||
) {
|
||||
const parentId = generateKsuidId();
|
||||
const rootId = generateKsuidId();
|
||||
const runId = generateKsuidId();
|
||||
const childId = generateKsuidId();
|
||||
|
||||
await seedRun(prisma, {
|
||||
id: rootId,
|
||||
friendlyId: `run_${rootId}`,
|
||||
runtimeEnvironmentId: base.runtimeEnvironmentId,
|
||||
projectId: base.projectId,
|
||||
organizationId: base.organizationId,
|
||||
});
|
||||
await seedRun(prisma, {
|
||||
id: parentId,
|
||||
friendlyId: `run_${parentId}`,
|
||||
runtimeEnvironmentId: base.runtimeEnvironmentId,
|
||||
projectId: base.projectId,
|
||||
organizationId: base.organizationId,
|
||||
rootTaskRunId: rootId,
|
||||
});
|
||||
|
||||
const run = await seedRun(prisma, {
|
||||
id: runId,
|
||||
friendlyId: `run_${runId}`,
|
||||
runtimeEnvironmentId: base.runtimeEnvironmentId,
|
||||
projectId: base.projectId,
|
||||
organizationId: base.organizationId,
|
||||
parentTaskRunId: parentId,
|
||||
rootTaskRunId: rootId,
|
||||
runTags: ["alpha", "beta"],
|
||||
metadata: JSON.stringify({ k: base.suffix }),
|
||||
});
|
||||
|
||||
await seedRun(prisma, {
|
||||
id: childId,
|
||||
friendlyId: `run_${childId}`,
|
||||
runtimeEnvironmentId: base.runtimeEnvironmentId,
|
||||
projectId: base.projectId,
|
||||
organizationId: base.organizationId,
|
||||
parentTaskRunId: runId,
|
||||
rootTaskRunId: rootId,
|
||||
});
|
||||
|
||||
const attempt = await seedAttempt(prisma, {
|
||||
runId,
|
||||
runtimeEnvironmentId: base.runtimeEnvironmentId,
|
||||
projectId: base.projectId,
|
||||
suffix: base.suffix,
|
||||
});
|
||||
|
||||
return {
|
||||
run,
|
||||
runFriendlyId: `run_${runId}`,
|
||||
parentFriendlyId: `run_${parentId}`,
|
||||
rootFriendlyId: `run_${rootId}`,
|
||||
childFriendlyId: `run_${childId}`,
|
||||
attemptId: attempt.id,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbHolder.prisma = null;
|
||||
dbHolder.$replica = null;
|
||||
});
|
||||
|
||||
describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant)", () => {
|
||||
containerTest(
|
||||
"returns run + attempts + tree from the store read; resolveSchedule reads control-plane prisma",
|
||||
async ({ prisma }) => {
|
||||
// Single-DB shape: one PostgresRunStore over the one prisma/replica pair,
|
||||
// exactly as the production `runStore.server.ts` singleton constructs it.
|
||||
dbHolder.prisma = prisma;
|
||||
dbHolder.$replica = prisma;
|
||||
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
|
||||
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
|
||||
prisma,
|
||||
"single"
|
||||
);
|
||||
|
||||
// Control-plane schedule on the SAME single client.
|
||||
const scheduleId = generateKsuidId();
|
||||
await prisma.taskSchedule.create({
|
||||
data: {
|
||||
id: scheduleId,
|
||||
friendlyId: `sched_${scheduleId}`,
|
||||
externalId: "my-external-schedule",
|
||||
taskIdentifier: "my-task",
|
||||
generatorExpression: "0 * * * *",
|
||||
generatorDescription: "Every hour",
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const tree = await seedRunWithTree(prisma, {
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
suffix: "single",
|
||||
});
|
||||
|
||||
await prisma.taskRun.update({
|
||||
where: { id: tree.run.id },
|
||||
data: { scheduleId },
|
||||
});
|
||||
|
||||
const env = authEnv(organization, project, runtimeEnvironment);
|
||||
const found = await readFoundRunViaStore(store, tree.runFriendlyId, env.id);
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.isBuffered).toBe(false);
|
||||
expect(found!.friendlyId).toBe(tree.runFriendlyId);
|
||||
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
|
||||
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
|
||||
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
|
||||
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
|
||||
expect([...found!.runTags].sort()).toEqual(["alpha", "beta"]);
|
||||
|
||||
// Drive the full presenter `call()` — exercises the real control-plane
|
||||
// `resolveSchedule` against the module `prisma` (the container client).
|
||||
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
|
||||
|
||||
expect(out.schedule).toBeDefined();
|
||||
expect(out.schedule?.externalId).toBe("my-external-schedule");
|
||||
expect(out.schedule?.generator.expression).toBe("0 * * * *");
|
||||
expect(out.relatedRuns.parent?.id).toBe(tree.parentFriendlyId);
|
||||
expect(out.relatedRuns.root?.id).toBe(tree.rootFriendlyId);
|
||||
expect(out.relatedRuns.children.map((c) => c.id)).toEqual([tree.childFriendlyId]);
|
||||
expect(out.attemptCount).toBe(found!.attemptNumber ?? 0);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"resolveSchedule re-reads TaskSchedule off the control-plane prisma on every call (no caching)",
|
||||
async ({ prisma }) => {
|
||||
// Single-DB: this proves resolveSchedule re-reads `prisma.taskSchedule`
|
||||
// on each call() and reflects a delete (no stale cache). The structural
|
||||
// "schedule comes from the control-plane client, not the run-ops store"
|
||||
// separation is proven by the cross-DB test below (distinct schedule
|
||||
// client + an onLegacy=null check); in single-DB both views are the
|
||||
// same physical row, so this case cannot discriminate the two paths.
|
||||
dbHolder.prisma = prisma;
|
||||
dbHolder.$replica = prisma;
|
||||
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma });
|
||||
|
||||
const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv(
|
||||
prisma,
|
||||
"cp-inv"
|
||||
);
|
||||
|
||||
const scheduleId = generateKsuidId();
|
||||
await prisma.taskSchedule.create({
|
||||
data: {
|
||||
id: scheduleId,
|
||||
friendlyId: `sched_${scheduleId}`,
|
||||
taskIdentifier: "my-task",
|
||||
generatorExpression: "*/5 * * * *",
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const runId = generateKsuidId();
|
||||
await seedRun(prisma, {
|
||||
id: runId,
|
||||
friendlyId: `run_${runId}`,
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
const env = authEnv(organization, project, runtimeEnvironment);
|
||||
const found = await readFoundRunViaStore(store, `run_${runId}`, env.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.scheduleId).toBe(scheduleId);
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter(CURRENT_API_VERSION);
|
||||
const out = await presenter.call(found!, env);
|
||||
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
|
||||
|
||||
// Delete the control-plane TaskSchedule row; the run-ops row is
|
||||
// untouched. resolveSchedule must now return undefined — proving the
|
||||
// schedule comes from `prisma.taskSchedule`, NOT from the run-ops store.
|
||||
await prisma.taskSchedule.delete({ where: { id: scheduleId } });
|
||||
const out2 = await presenter.call(found!, env);
|
||||
expect(out2.schedule).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("ApiRetrieveRunPresenter.findRun cross-version read (PG14 + PG17)", () => {
|
||||
heteroPostgresTest(
|
||||
"single retrieve returns run + attempts + tree byte-identically from NEW (PG17) and LEGACY (PG14) stores",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
// NEW residency subgraph on PG17.
|
||||
const newEnv = await seedOrgProjectEnv(prisma17, "new");
|
||||
const newTree = await seedRunWithTree(prisma17, {
|
||||
runtimeEnvironmentId: newEnv.runtimeEnvironment.id,
|
||||
projectId: newEnv.project.id,
|
||||
organizationId: newEnv.organization.id,
|
||||
suffix: "new",
|
||||
});
|
||||
|
||||
// Distinct LEGACY residency subgraph on PG14.
|
||||
const legacyEnv = await seedOrgProjectEnv(prisma14, "legacy");
|
||||
const legacyTree = await seedRunWithTree(prisma14, {
|
||||
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
|
||||
projectId: legacyEnv.project.id,
|
||||
organizationId: legacyEnv.organization.id,
|
||||
suffix: "legacy",
|
||||
});
|
||||
|
||||
// Read the NEW run from the PG17 store.
|
||||
const foundNew = await readFoundRunViaStore(
|
||||
newStore,
|
||||
newTree.runFriendlyId,
|
||||
newEnv.runtimeEnvironment.id
|
||||
);
|
||||
expect(foundNew).not.toBeNull();
|
||||
expect(foundNew!.friendlyId).toBe(newTree.runFriendlyId);
|
||||
expect(foundNew!.parentTaskRun?.friendlyId).toBe(newTree.parentFriendlyId);
|
||||
expect(foundNew!.rootTaskRun?.friendlyId).toBe(newTree.rootFriendlyId);
|
||||
expect(foundNew!.childRuns.map((c) => c.friendlyId)).toEqual([newTree.childFriendlyId]);
|
||||
expect(foundNew!.attempts.map((a) => a.id)).toEqual([newTree.attemptId]);
|
||||
expect([...foundNew!.runTags].sort()).toEqual(["alpha", "beta"]);
|
||||
expect(foundNew!.metadata).toBe(JSON.stringify({ k: "new" }));
|
||||
|
||||
// Sanity: the two stores are genuinely distinct DBs — the NEW run's key
|
||||
// is absent from PG14 (it was only ever written to PG17).
|
||||
const leaked = await readFoundRunViaStore(
|
||||
legacyStore,
|
||||
newTree.runFriendlyId,
|
||||
newEnv.runtimeEnvironment.id
|
||||
);
|
||||
expect(leaked).toBeNull();
|
||||
|
||||
// Read the LEGACY run from the PG14 store — byte-identical tree.
|
||||
const foundLegacy = await readFoundRunViaStore(
|
||||
legacyStore,
|
||||
legacyTree.runFriendlyId,
|
||||
legacyEnv.runtimeEnvironment.id
|
||||
);
|
||||
expect(foundLegacy).not.toBeNull();
|
||||
expect(foundLegacy!.friendlyId).toBe(legacyTree.runFriendlyId);
|
||||
expect(foundLegacy!.parentTaskRun?.friendlyId).toBe(legacyTree.parentFriendlyId);
|
||||
expect(foundLegacy!.rootTaskRun?.friendlyId).toBe(legacyTree.rootFriendlyId);
|
||||
expect(foundLegacy!.childRuns.map((c) => c.friendlyId)).toEqual([legacyTree.childFriendlyId]);
|
||||
expect(foundLegacy!.attempts.map((a) => a.id)).toEqual([legacyTree.attemptId]);
|
||||
expect(foundLegacy!.metadata).toBe(JSON.stringify({ k: "legacy" }));
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"schedule resolved cross-DB: run hydrated from run-ops store (PG14), schedule from a distinct control-plane client (PG17)",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
// Run-ops residency = LEGACY (PG14). Control-plane (TaskSchedule) lives
|
||||
// on a DISTINCT client — PG17 — and is the handle the module `prisma`
|
||||
// resolves to in this test. This proves the run-ops-row -> control-plane
|
||||
// -schedule cross-seam read: the run comes from one DB, the schedule
|
||||
// from another.
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
// Module control-plane handle -> PG17.
|
||||
dbHolder.prisma = prisma17;
|
||||
dbHolder.$replica = prisma17;
|
||||
|
||||
const legacyEnv = await seedOrgProjectEnv(prisma14, "x-run");
|
||||
// The control-plane project the schedule hangs off lives on PG17.
|
||||
const cpEnv = await seedOrgProjectEnv(prisma17, "x-cp");
|
||||
|
||||
const scheduleId = generateKsuidId();
|
||||
await prisma17.taskSchedule.create({
|
||||
data: {
|
||||
id: scheduleId,
|
||||
friendlyId: `sched_${scheduleId}`,
|
||||
externalId: "cross-db-schedule",
|
||||
taskIdentifier: "my-task",
|
||||
generatorExpression: "0 0 * * *",
|
||||
projectId: cpEnv.project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const runId = generateKsuidId();
|
||||
await seedRun(prisma14, {
|
||||
id: runId,
|
||||
friendlyId: `run_${runId}`,
|
||||
runtimeEnvironmentId: legacyEnv.runtimeEnvironment.id,
|
||||
projectId: legacyEnv.project.id,
|
||||
organizationId: legacyEnv.organization.id,
|
||||
scheduleId,
|
||||
});
|
||||
|
||||
const env = authEnv(legacyEnv.organization, legacyEnv.project, legacyEnv.runtimeEnvironment);
|
||||
const found = await readFoundRunViaStore(
|
||||
legacyStore,
|
||||
`run_${runId}`,
|
||||
legacyEnv.runtimeEnvironment.id
|
||||
);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.scheduleId).toBe(scheduleId);
|
||||
|
||||
// The schedule row does NOT exist on the run-ops (PG14) client.
|
||||
const onLegacy = await prisma14.taskSchedule.findFirst({ where: { id: scheduleId } });
|
||||
expect(onLegacy).toBeNull();
|
||||
|
||||
const out = await new ApiRetrieveRunPresenter(CURRENT_API_VERSION).call(found!, env);
|
||||
expect(out.schedule).toBeDefined();
|
||||
expect(out.schedule?.id).toBe(`sched_${scheduleId}`);
|
||||
expect(out.schedule?.externalId).toBe("cross-db-schedule");
|
||||
expect(out.schedule?.generator.expression).toBe("0 0 * * *");
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"correct past-retention / not-found response: both stores miss => findRun returns null",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const newStore = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
const legacyStore = new PostgresRunStore({ prisma: prisma14, readOnlyPrisma: prisma14 });
|
||||
|
||||
const newEnv = await seedOrgProjectEnv(prisma17, "miss-new");
|
||||
const legacyEnv = await seedOrgProjectEnv(prisma14, "miss-legacy");
|
||||
|
||||
// A run that exists on NEITHER store (terminated + past-retention,
|
||||
// observed at this layer as a miss on both underlying stores).
|
||||
const goneFriendlyId = `run_${generateKsuidId()}`;
|
||||
|
||||
const fromNew = await readFoundRunViaStore(
|
||||
newStore,
|
||||
goneFriendlyId,
|
||||
newEnv.runtimeEnvironment.id
|
||||
);
|
||||
const fromLegacy = await readFoundRunViaStore(
|
||||
legacyStore,
|
||||
goneFriendlyId,
|
||||
legacyEnv.runtimeEnvironment.id
|
||||
);
|
||||
|
||||
expect(fromNew).toBeNull();
|
||||
expect(fromLegacy).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"single-DB passthrough: one PostgresRunStore over one client hydrates run + tree (self-host collapse)",
|
||||
async ({ prisma17 }) => {
|
||||
// The single-DB collapse: one plain PostgresRunStore over one client.
|
||||
// No legacy probe / known-migrated machinery at this layer.
|
||||
const store = new PostgresRunStore({ prisma: prisma17, readOnlyPrisma: prisma17 });
|
||||
|
||||
const onlyEnv = await seedOrgProjectEnv(prisma17, "passthrough");
|
||||
const tree = await seedRunWithTree(prisma17, {
|
||||
runtimeEnvironmentId: onlyEnv.runtimeEnvironment.id,
|
||||
projectId: onlyEnv.project.id,
|
||||
organizationId: onlyEnv.organization.id,
|
||||
suffix: "passthrough",
|
||||
});
|
||||
|
||||
const found = await readFoundRunViaStore(
|
||||
store,
|
||||
tree.runFriendlyId,
|
||||
onlyEnv.runtimeEnvironment.id
|
||||
);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.isBuffered).toBe(false);
|
||||
expect(found!.parentTaskRun?.friendlyId).toBe(tree.parentFriendlyId);
|
||||
expect(found!.rootTaskRun?.friendlyId).toBe(tree.rootFriendlyId);
|
||||
expect(found!.childRuns.map((c) => c.friendlyId)).toEqual([tree.childFriendlyId]);
|
||||
expect(found!.attempts.map((a) => a.id)).toEqual([tree.attemptId]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter graph imports `~/v3/runStore.server` (via RunsRepository) which imports
|
||||
// `~/db.server` at load, and the presenter itself reaches `~/db.server`'s `$replica` singleton
|
||||
// through `findDisplayableEnvironment` and `getTaskIdentifiers`. Stub the module so those
|
||||
// singleton reads resolve. This is the ONLY mock — the DB is NEVER mocked; the proxy delegates
|
||||
// to the per-test REAL legacy (PG14) container so the env-lookup + task-identifier reads hit a
|
||||
// real database. Everything asserted runs against real containers. Mirrors
|
||||
// nextRunListPresenter.readthrough.test.ts.
|
||||
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
// `ApiRunListPresenter` resolves its read ClickHouse internally via the `clickhouseFactory`
|
||||
// singleton (which imports `~/env.server` and binds to a process-wide default client). Stub the
|
||||
// instance module so `getClickhouseForOrganization` returns the per-test container's ClickHouse
|
||||
// handle (set by each test before calling). This is a module-resolution shim — the ClickHouse is
|
||||
// a REAL testcontainer, never mocked — mirroring the `~/db.server` stub below.
|
||||
const clickhouseHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
|
||||
clickhouseFactory: {
|
||||
getClickhouseForOrganization: async () => {
|
||||
if (!clickhouseHolder.client) {
|
||||
throw new Error("clickhouseHolder.client not set for this test");
|
||||
}
|
||||
return clickhouseHolder.client;
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
const lazyProxy = (holder: { client: any }, label: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (!holder.client) {
|
||||
throw new Error(`${label} not set for this test`);
|
||||
}
|
||||
return holder.client[prop];
|
||||
},
|
||||
}
|
||||
);
|
||||
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
|
||||
const newProxy = lazyProxy(newClientHolder, "newClientHolder.client");
|
||||
return {
|
||||
prisma: replicaProxy,
|
||||
$replica: replicaProxy,
|
||||
runOpsNewPrisma: newProxy,
|
||||
runOpsNewReplica: newProxy,
|
||||
runOpsLegacyPrisma: replicaProxy,
|
||||
runOpsLegacyReplica: replicaProxy,
|
||||
sqlDatabaseSchema: Prisma.sql([`public`]),
|
||||
};
|
||||
});
|
||||
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { CURRENT_API_VERSION } from "~/api/versions";
|
||||
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { setupClickhouseReplication } from "./utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
environmentSlug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
|
||||
* exist on every DB a run lives on, so identical parents (same ids) are seeded on both the
|
||||
* legacy (PG14) and new (PG17) databases.
|
||||
*/
|
||||
async function seedParents(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
envSlug = `env-${slug}`
|
||||
): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: envSlug,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
environmentSlug: runtimeEnvironment.slug,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds an extra RuntimeEnvironment (control-plane row) to an existing project. */
|
||||
async function addEnvironment(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
slug: string,
|
||||
envSlug: string
|
||||
): Promise<string> {
|
||||
const env = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: envSlug,
|
||||
type: "STAGING",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_${envSlug}_${slug}`,
|
||||
pkApiKey: `pk_${envSlug}_${slug}`,
|
||||
shortcode: `sc-${envSlug}-${slug}`,
|
||||
},
|
||||
});
|
||||
return env.id;
|
||||
}
|
||||
|
||||
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: ctx.environmentSlug,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_dev_${slug}_b`,
|
||||
pkApiKey: `pk_dev_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: {
|
||||
friendlyId: string;
|
||||
taskIdentifier?: string;
|
||||
status?: any;
|
||||
runtimeEnvironmentId?: string;
|
||||
}
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: run.taskIdentifier ?? "my-task",
|
||||
status: run.status ?? "PENDING",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "test",
|
||||
runTags: [],
|
||||
runtimeEnvironmentId: run.runtimeEnvironmentId ?? ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => {
|
||||
// Public list serves run-ops rows through the routed store. The
|
||||
// forwarded readThroughDeps thread the dual-DB union into NextRunListPresenter; the public
|
||||
// payload (`{ data, pagination }`) must list the NEW ∪ legacy union, proving the public API
|
||||
// surfaces routed run-ops rows. The migrated/straggler rows (run_newA/run_newB) live on BOTH
|
||||
// DBs with the same id + friendlyId but a DISTINGUISHING taskIdentifier ("my-task-NEW" on PG17),
|
||||
// so a row served from the threaded newClient is identifiable in the public payload.
|
||||
replicationContainerTest(
|
||||
"public payload lists run-ops rows served via the routed store (NEW + legacy union)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
clickhouseHolder.client = clickhouse;
|
||||
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> PG17.
|
||||
newClientHolder.client = prismaNew;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "hydrate");
|
||||
await mirrorParents(prismaNew, ctx, "hydrate");
|
||||
|
||||
// All four runs land on PG14 (legacy + replication source -> CH gets the full id-set).
|
||||
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
|
||||
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
|
||||
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
|
||||
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
|
||||
|
||||
// The two "migrated" runs also live on NEW (authoritative during retention), same ids +
|
||||
// friendlyIds, but a DISTINGUISHING taskIdentifier so a row served from PG17 is
|
||||
// identifiable in the public payload.
|
||||
await createRun(prismaNew, ctx, { friendlyId: "run_newA", taskIdentifier: "my-task-NEW" });
|
||||
await createRun(prismaNew, ctx, { friendlyId: "run_newB", taskIdentifier: "my-task-NEW" });
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_newA" },
|
||||
data: { id: migratedA.id },
|
||||
});
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_newB" },
|
||||
data: { id: migratedB.id },
|
||||
});
|
||||
|
||||
// Wait for CH replication so the id-set page is non-empty.
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new ApiRunListPresenter(prisma, prisma, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(
|
||||
{ id: ctx.projectId },
|
||||
{ "page[size]": 10 } as any,
|
||||
CURRENT_API_VERSION,
|
||||
{ id: ctx.environmentId, organizationId: ctx.organizationId }
|
||||
);
|
||||
|
||||
// The public payload lists runs by `id` = `run.friendlyId`, id-desc ordered.
|
||||
const expectedFriendlyIds = [
|
||||
{ id: migratedA.id, friendlyId: "run_newA" },
|
||||
{ id: migratedB.id, friendlyId: "run_newB" },
|
||||
{ id: legacyOnlyA.id, friendlyId: "run_legacyA" },
|
||||
{ id: legacyOnlyB.id, friendlyId: "run_legacyB" },
|
||||
]
|
||||
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
||||
.map((r) => r.friendlyId);
|
||||
expect(result.data.map((r) => r.id)).toEqual(expectedFriendlyIds);
|
||||
|
||||
// The migrated rows must carry the PG17-only taskIdentifier — only possible if the public
|
||||
// path hydrated them through the threaded newClient (PG17). taskKind falls back to STANDARD.
|
||||
const migratedRow = result.data.find((r) => r.id === "run_newA");
|
||||
expect(migratedRow?.taskIdentifier).toBe("my-task-NEW");
|
||||
expect(migratedRow?.taskKind).toBe("STANDARD");
|
||||
// The legacy-only rows surface from PG14, proving the legacyReplica is also exercised.
|
||||
expect(result.data.find((r) => r.id === "run_legacyA")?.taskIdentifier).toBe("my-task");
|
||||
|
||||
// Pagination shape is present.
|
||||
expect(result.pagination).toHaveProperty("next");
|
||||
expect(result.pagination).toHaveProperty("previous");
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Genuinely-empty env returns { data: [], pagination } without error. Exercises the
|
||||
// empty-state probe beneath NextRunListPresenter (no rows on either DB; empty CH page).
|
||||
replicationContainerTest(
|
||||
"genuinely-empty env returns { data: [], pagination } without error",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
clickhouseHolder.client = clickhouse;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "empty");
|
||||
await mirrorParents(prismaNew, ctx, "empty");
|
||||
|
||||
const presenter = new ApiRunListPresenter(prisma, prisma, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(
|
||||
{ id: ctx.projectId },
|
||||
{ "page[size]": 10 } as any,
|
||||
CURRENT_API_VERSION,
|
||||
{ id: ctx.environmentId, organizationId: ctx.organizationId }
|
||||
);
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.pagination).toHaveProperty("next");
|
||||
expect(result.pagination).toHaveProperty("previous");
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Env scoping unchanged: the control-plane runtimeEnvironment.findMany lookup
|
||||
// resolves the requested env via the `_replica` handle (NOT routed), with the 4th `environment`
|
||||
// arg omitted to force that branch. Result is scoped to the requested env only.
|
||||
replicationContainerTest(
|
||||
"env scoping resolves via the control-plane _replica handle (filter[env], 4th arg omitted)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
legacyReplicaHolder.client = prisma;
|
||||
clickhouseHolder.client = clickhouse;
|
||||
|
||||
const ctx = await seedParents(prisma, "scoping", "prod");
|
||||
const stagingEnvId = await addEnvironment(prisma, ctx, "scoping", "staging");
|
||||
|
||||
// Runs in prod only; a run in staging must NOT surface when filter[env]=prod.
|
||||
await createRun(prisma, ctx, { friendlyId: "run_prod1" });
|
||||
await createRun(prisma, ctx, { friendlyId: "run_prod2" });
|
||||
await createRun(prisma, ctx, {
|
||||
friendlyId: "run_staging",
|
||||
runtimeEnvironmentId: stagingEnvId,
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// Single-handle passthrough; the env lookup runs on `_replica` (= prisma) via findMany.
|
||||
const presenter = new ApiRunListPresenter(prisma, prisma);
|
||||
|
||||
// 4th `environment` arg OMITTED -> forces the runtimeEnvironment.findMany branch.
|
||||
const result = await presenter.call(
|
||||
{ id: ctx.projectId },
|
||||
{ "page[size]": 10, "filter[env]": ["prod"] } as any,
|
||||
CURRENT_API_VERSION
|
||||
);
|
||||
|
||||
// Scoped to the resolved prod env only.
|
||||
expect(result.data.map((r) => r.id).sort()).toEqual(["run_prod1", "run_prod2"]);
|
||||
}
|
||||
);
|
||||
|
||||
// Passthrough (single-DB): two-arg-style construction (no readThroughDeps) ->
|
||||
// NextRunListPresenter receives undefined deps -> byte-identical single-DB path. The public
|
||||
// { data, pagination } shape is unchanged.
|
||||
replicationContainerTest(
|
||||
"single-DB passthrough: no readThroughDeps lists the seeded runs unchanged",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
legacyReplicaHolder.client = prisma;
|
||||
clickhouseHolder.client = clickhouse;
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough");
|
||||
await createRun(prisma, ctx, { friendlyId: "run_pt1" });
|
||||
await createRun(prisma, ctx, { friendlyId: "run_pt2" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// No readThroughDeps -> passthrough, exactly as the routes construct it today.
|
||||
const presenter = new ApiRunListPresenter(prisma, prisma);
|
||||
|
||||
const result = await presenter.call(
|
||||
{ id: ctx.projectId },
|
||||
{ "page[size]": 10 } as any,
|
||||
CURRENT_API_VERSION,
|
||||
{ id: ctx.environmentId, organizationId: ctx.organizationId }
|
||||
);
|
||||
|
||||
expect(result.data.map((r) => r.id).sort()).toEqual(["run_pt1", "run_pt2"]);
|
||||
expect(result).toHaveProperty("pagination");
|
||||
expect(result.pagination).toHaveProperty("next");
|
||||
expect(result.pagination).toHaveProperty("previous");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
// Read-through proof for the public single-run result poll (ApiRunResultPresenter). The presenter
|
||||
// routes its TaskRun(+attempts) lookup-by-friendlyId through readThroughRun: split mode resolves
|
||||
// from new first then the legacy READ REPLICA on a new-probe miss (never a primary),
|
||||
// past-retention → undefined → the route's normal 404; single-DB is one plain findFirst. NEVER mock
|
||||
// the DB — the cross-version proof uses a heterogeneous legacy+new Postgres fixture; only pure
|
||||
// boundaries (splitEnabled/isPastRetention) are injected.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
|
||||
// Neutralize the db.server singleton so importing the presenter (via BasePresenter) and
|
||||
// readThrough.server (which imports db.server defaults) does not try to connect to the env
|
||||
// database. Every read in this file goes through clients we inject explicitly.
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
|
||||
|
||||
// Residency by friendlyId length (after stripping `run_`): 27-char body → NEW (ksuid analog),
|
||||
// 25-char body → LEGACY (cuid analog). ownerEngine classifies on the public friendly id.
|
||||
function newFriendlyId(): string {
|
||||
return "run_" + customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 27)();
|
||||
}
|
||||
function legacyFriendlyId(): string {
|
||||
return "run_" + customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 25)();
|
||||
}
|
||||
|
||||
function authEnv(environmentId: string): AuthenticatedEnvironment {
|
||||
// The presenter only reads env.id (the runtimeEnvironmentId filter) and the tracing attrs.
|
||||
return {
|
||||
id: environmentId,
|
||||
project: { id: "p", name: "p" },
|
||||
organization: { id: "o", title: "o" },
|
||||
orgMember: null,
|
||||
} as unknown as AuthenticatedEnvironment;
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
backgroundWorkerId: string;
|
||||
backgroundWorkerTaskId: string;
|
||||
queueId: string;
|
||||
};
|
||||
|
||||
async function seedEnv(prisma: PrismaClient, slug: string) {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `${slug}@test.com`, name: "t", authenticationMethod: "MAGIC_LINK" },
|
||||
});
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: "Org",
|
||||
slug: `org-${slug}-${idGenerator()}`,
|
||||
members: { create: { userId: user.id, role: "ADMIN" } },
|
||||
},
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: "Proj",
|
||||
slug: `proj-${slug}-${idGenerator()}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `ext-${slug}-${idGenerator()}`,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `api-${slug}-${idGenerator()}`,
|
||||
pkApiKey: `pk-${slug}-${idGenerator()}`,
|
||||
shortcode: `sc-${slug}-${idGenerator()}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
async function seedWorker(prisma: PrismaClient, ctx: { environmentId: string; projectId: string }) {
|
||||
const queue = await prisma.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: `queue_${idGenerator()}`,
|
||||
name: "task/test-task",
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
},
|
||||
});
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${idGenerator()}`,
|
||||
contentHash: "hash",
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
version: "20240101.1",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
const task = await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${idGenerator()}`,
|
||||
slug: "test-task",
|
||||
filePath: "src/test.ts",
|
||||
exportName: "testTask",
|
||||
workerId: worker.id,
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
},
|
||||
});
|
||||
return { queueId: queue.id, backgroundWorkerId: worker.id, backgroundWorkerTaskId: task.id };
|
||||
}
|
||||
|
||||
async function fullSeed(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const { organization, project, environment } = await seedEnv(prisma, slug);
|
||||
const worker = await seedWorker(prisma, {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
return {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
...worker,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedRunWithAttempt(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
friendlyId: string,
|
||||
opts: {
|
||||
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS" | "CANCELED" | "EXECUTING";
|
||||
attempt?: {
|
||||
status: "COMPLETED" | "FAILED";
|
||||
output?: string;
|
||||
outputType?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const runInternalId = idGenerator();
|
||||
const run = await prisma.taskRun.create({
|
||||
data: {
|
||||
id: runInternalId,
|
||||
friendlyId,
|
||||
taskIdentifier: "test-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceId: idGenerator(),
|
||||
spanId: idGenerator(),
|
||||
queue: "task/test-task",
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
status: opts.status,
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.attempt) {
|
||||
await prisma.taskRunAttempt.create({
|
||||
data: {
|
||||
friendlyId: `attempt_${idGenerator()}`,
|
||||
taskRunId: run.id,
|
||||
backgroundWorkerId: ctx.backgroundWorkerId,
|
||||
backgroundWorkerTaskId: ctx.backgroundWorkerTaskId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
queueId: ctx.queueId,
|
||||
status: opts.attempt.status,
|
||||
output: opts.attempt.output,
|
||||
outputType: opts.attempt.outputType ?? "application/json",
|
||||
error: opts.attempt.error as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
// A legacy-replica closure that explodes if ever touched — used to prove the primary/legacy store
|
||||
// is structurally unreachable when it must not be read.
|
||||
function throwingLegacy(): PrismaReplicaClient {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("legacy replica must never be read in this case");
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaReplicaClient;
|
||||
}
|
||||
|
||||
describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgres)", () => {
|
||||
heteroPostgresTest(
|
||||
"split: a run living on the NEW DB resolves from new and never probes the legacy replica",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const friendlyId = newFriendlyId();
|
||||
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "new-only");
|
||||
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"hello"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
expect(result.id).toBe(friendlyId);
|
||||
expect(result.taskIdentifier).toBe("test-task");
|
||||
expect(result.output).toBe('"hello"');
|
||||
expect(result.outputType).toBe("application/json");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Old legacy-only run resolves from the legacy read replica (cross-version). The only legacy
|
||||
// handle exposed is a read replica — no writer/primary field exists in this path.
|
||||
heteroPostgresTest(
|
||||
"split: an OLD legacy-only run resolves from the legacy read replica across the version boundary",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const friendlyId = legacyFriendlyId();
|
||||
// Seed only on legacy. New gets just an env so the new-probe runs but misses.
|
||||
const legacyCtx = await fullSeed(prisma14 as unknown as PrismaClient, "legacy-only");
|
||||
await fullSeed(prisma17 as unknown as PrismaClient, "new-empty");
|
||||
await seedRunWithAttempt(prisma14 as unknown as PrismaClient, legacyCtx, friendlyId, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"from-legacy"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma14 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(legacyCtx.environmentId));
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
// friendlyId / taskIdentifier / output+outputType round-trip across the version boundary identically.
|
||||
expect(result.id).toBe(friendlyId);
|
||||
expect(result.taskIdentifier).toBe("test-task");
|
||||
expect(result.output).toBe('"from-legacy"');
|
||||
expect(result.outputType).toBe("application/json");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Legacy-classified id present on neither store, isPastRetention=true → past-retention → undefined.
|
||||
heteroPostgresTest(
|
||||
"split: a past-retention id returns undefined (the route's normal 404 surface)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const friendlyId = legacyFriendlyId();
|
||||
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "past-ret-new");
|
||||
await fullSeed(prisma14 as unknown as PrismaClient, "past-ret-legacy");
|
||||
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma14 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
isPastRetention: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
// Identical surface to a genuinely missing run: the route maps undefined → normal 404.
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"single-DB passthrough: resolves from the one client; the legacy replica is never touched",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const friendlyId = newFriendlyId();
|
||||
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "passthrough");
|
||||
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"single"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
// No read-through deps → passthrough (single plain findFirst).
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
expect(result.id).toBe(friendlyId);
|
||||
expect(result.output).toBe('"single"');
|
||||
}
|
||||
|
||||
// splitEnabled:false with a throwing legacy proves no second store is touched.
|
||||
const presenter2 = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: false,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
);
|
||||
const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId));
|
||||
expect(result2?.ok).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// executionResultForTaskRun mapping is identical across split and single-DB for every status.
|
||||
heteroPostgresTest(
|
||||
"status parity: success / failed / canceled map identically in split and single-DB",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "parity");
|
||||
|
||||
const successId = newFriendlyId();
|
||||
const failedId = newFriendlyId();
|
||||
const canceledId = newFriendlyId();
|
||||
|
||||
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, successId, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"ok"', outputType: "application/json" },
|
||||
});
|
||||
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, failedId, {
|
||||
status: "COMPLETED_WITH_ERRORS",
|
||||
attempt: {
|
||||
status: "FAILED",
|
||||
error: { type: "BUILT_IN_ERROR", name: "Error", message: "boom", stackTrace: "boom" },
|
||||
},
|
||||
});
|
||||
await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, canceledId, {
|
||||
status: "CANCELED",
|
||||
});
|
||||
|
||||
const splitPresenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
);
|
||||
const passthroughPresenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient
|
||||
);
|
||||
|
||||
for (const id of [successId, failedId, canceledId]) {
|
||||
const split = await splitPresenter.call(id, authEnv(ctx.environmentId));
|
||||
const single = await passthroughPresenter.call(id, authEnv(ctx.environmentId));
|
||||
expect(split).toEqual(single);
|
||||
}
|
||||
|
||||
const success = await splitPresenter.call(successId, authEnv(ctx.environmentId));
|
||||
expect(success?.ok).toBe(true);
|
||||
|
||||
const failed = await splitPresenter.call(failedId, authEnv(ctx.environmentId));
|
||||
expect(failed?.ok).toBe(false);
|
||||
|
||||
const canceled = await splitPresenter.call(canceledId, authEnv(ctx.environmentId));
|
||||
expect(canceled?.ok).toBe(false);
|
||||
if (canceled && !canceled.ok) {
|
||||
expect(canceled.error.type).toBe("INTERNAL_ERROR");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
var dbClientHolder: any = undefined;
|
||||
function setDbClient(client: any) {
|
||||
dbClientHolder = client;
|
||||
}
|
||||
|
||||
vi.mock("~/v3/services/baseService.server", async () => {
|
||||
const { ServiceValidationError } = await import("~/v3/services/common.server");
|
||||
return { ServiceValidationError };
|
||||
});
|
||||
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
return {
|
||||
Prisma,
|
||||
sqlDatabaseSchema: Prisma.sql(["public"]),
|
||||
get prisma() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
get $replica() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { ApiWaitpointListPresenter } from "~/presenters/v3/ApiWaitpointListPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
const ENV_ID = "env0000000000000000t19";
|
||||
const PROJ_ID = "proj00000000000000t19";
|
||||
|
||||
async function seedLegacyParents(prisma: PrismaClient, slug: string) {
|
||||
const org = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: PROJ_ID,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: org.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ENV_ID,
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: PROJ_ID,
|
||||
organizationId: org.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const baseEnv = {
|
||||
id: ENV_ID,
|
||||
type: "PRODUCTION" as const,
|
||||
project: { id: PROJ_ID, engine: "V2" as const },
|
||||
apiKey: "tr_prod_t19",
|
||||
};
|
||||
|
||||
describe("ApiWaitpointListPresenter read-route threading", () => {
|
||||
postgresTest(
|
||||
"split enabled: waitpoint on NEW handle is returned via readRoute",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
await seedLegacyParents(prisma, "t19split");
|
||||
|
||||
await prisma.waitpoint.create({
|
||||
data: {
|
||||
id: "wp_t19_0000000000000000001",
|
||||
friendlyId: "wpt_t19_001",
|
||||
type: "MANUAL",
|
||||
status: "PENDING",
|
||||
idempotencyKey: "idem-t19-001",
|
||||
userProvidedIdempotencyKey: false,
|
||||
projectId: PROJ_ID,
|
||||
environmentId: ENV_ID,
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new ApiWaitpointListPresenter(undefined, undefined, {
|
||||
runOpsNew: prisma as any,
|
||||
runOpsLegacyReplica: prisma as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(baseEnv, {});
|
||||
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
expect(result.data.some((t) => t.id === "wpt_t19_001")).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest(
|
||||
"passthrough: no readRoute => _replica only, result empty (nothing seeded)",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
await seedLegacyParents(prisma, "t19pass");
|
||||
|
||||
const presenter = new ApiWaitpointListPresenter(prisma, prisma);
|
||||
|
||||
const result = await presenter.call(baseEnv, {});
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
// Real heterogeneous legacy + new Postgres proof for the public waitpoint retrieve read.
|
||||
// The DB is never mocked: reads hit the two real containers. Only pure boundaries
|
||||
// (splitEnabled, isPastRetention) and recording client wrappers are
|
||||
// injected. heteroPostgresTest runs the legacy and new databases on different major versions.
|
||||
import {
|
||||
heteroPostgresTest,
|
||||
heteroRunOpsPostgresTest,
|
||||
postgresTest,
|
||||
} from "@internal/testcontainers";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
|
||||
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// 25-char cuid body (length-disjoint from the 27-char KSUID) → LEGACY residency.
|
||||
function generateLegacyCuid() {
|
||||
const suffix = Array.from(
|
||||
{ length: 24 },
|
||||
() => "0123456789abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 36)]
|
||||
).join("");
|
||||
return `c${suffix}`;
|
||||
}
|
||||
|
||||
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
|
||||
// forbidden, so we can prove a store was NEVER read.
|
||||
function recording(client: PrismaClient | RunOpsPrismaClient, opts: { forbidden?: boolean } = {}) {
|
||||
const calls: unknown[] = [];
|
||||
const waitpoint = {
|
||||
findFirst: (args: unknown) => {
|
||||
calls.push(args);
|
||||
if (opts.forbidden) {
|
||||
throw new Error("this store must never be read");
|
||||
}
|
||||
return (client as unknown as PrismaReplicaClient).waitpoint.findFirst(args as never);
|
||||
},
|
||||
};
|
||||
return { handle: { ...client, waitpoint } as unknown as PrismaReplicaClient, calls };
|
||||
}
|
||||
|
||||
async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `test-${suffix}`, slug: `test-${suffix}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `test-${suffix}`,
|
||||
slug: `test-${suffix}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `test-${suffix}`,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `test-${suffix}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `apikey-${suffix}`,
|
||||
pkApiKey: `pk-${suffix}`,
|
||||
shortcode: `test-${suffix}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
async function seedWaitpoint(
|
||||
prisma: PrismaClient,
|
||||
id: string,
|
||||
env: { id: string; projectId: string },
|
||||
overrides: Partial<{
|
||||
status: "PENDING" | "COMPLETED";
|
||||
type: WaitpointType;
|
||||
output: string;
|
||||
outputType: string;
|
||||
outputIsError: boolean;
|
||||
completedAt: Date;
|
||||
completedAfter: Date;
|
||||
tags: string[];
|
||||
}> = {}
|
||||
) {
|
||||
return prisma.waitpoint.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId: `waitpoint_${id}`,
|
||||
type: overrides.type ?? "MANUAL",
|
||||
status: overrides.status ?? "COMPLETED",
|
||||
idempotencyKey: `idem-${id}`,
|
||||
userProvidedIdempotencyKey: false,
|
||||
output: overrides.output ?? JSON.stringify({ hello: "world" }),
|
||||
outputType: overrides.outputType ?? "application/json",
|
||||
outputIsError: overrides.outputIsError ?? false,
|
||||
completedAt: overrides.completedAt ?? new Date(),
|
||||
completedAfter: overrides.completedAfter,
|
||||
tags: overrides.tags ?? ["a", "b"],
|
||||
projectId: env.projectId,
|
||||
environmentId: env.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const environmentArg = (env: { id: string; projectId: string }) => ({
|
||||
id: env.id,
|
||||
type: "PRODUCTION" as const,
|
||||
project: { id: env.projectId, engine: "V2" as const },
|
||||
apiKey: "tr_test_apikey",
|
||||
});
|
||||
|
||||
describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgres)", () => {
|
||||
heteroPostgresTest(
|
||||
"resolves on run-ops NEW (ksuid), legacy replica never touched",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateKsuidId();
|
||||
expect(id.length).toBe(27);
|
||||
|
||||
const { project, environment } = await seedOrgProjectEnv(prisma17, "new");
|
||||
const seeded = await seedWaitpoint(
|
||||
prisma17,
|
||||
id,
|
||||
{ id: environment.id, projectId: project.id },
|
||||
{ tags: ["x", "y", "z"], output: JSON.stringify({ n: 42 }) }
|
||||
);
|
||||
|
||||
const newClient = recording(prisma17);
|
||||
const legacy = recording(prisma14, { forbidden: true });
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
expect(result.id).toBe(seeded.friendlyId);
|
||||
expect(result.tags).toEqual(["x", "y", "z"]);
|
||||
expect(result.output).toBe(JSON.stringify({ n: 42 }));
|
||||
expect(result.type).toBe("MANUAL");
|
||||
// ksuid → NEW: new store served the read, legacy never touched (fast-path).
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"resolves off the LEGACY replica (cuid), never a legacy primary",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateLegacyCuid();
|
||||
expect(id.length).toBe(25);
|
||||
|
||||
const { project, environment } = await seedOrgProjectEnv(prisma14, "legacy");
|
||||
const seeded = await seedWaitpoint(prisma14, id, {
|
||||
id: environment.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const newClient = recording(prisma17);
|
||||
// The deps expose only legacyReplica — there is NO legacy-primary handle at all.
|
||||
const legacy = recording(prisma14);
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
expect(result.id).toBe(seeded.friendlyId);
|
||||
expect(result.tags).toEqual(["a", "b"]);
|
||||
// NEW probed first (miss) → resolved off the LEGACY REPLICA handle.
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"not-found maps to the existing ServiceValidationError surface",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateLegacyCuid();
|
||||
const { environment } = await seedOrgProjectEnv(prisma14, "nf");
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
});
|
||||
|
||||
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
|
||||
"Waitpoint not found"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"past-retention maps to the same not-found surface",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateLegacyCuid();
|
||||
const { environment } = await seedOrgProjectEnv(prisma14, "pr");
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
isPastRetention: () => true,
|
||||
});
|
||||
|
||||
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
|
||||
"Waitpoint not found"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"cross-seam — new-resident served from NEW (legacy untouched); in-retention served from legacy",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
// New-resident waitpoint: lives on NEW, the new probe hits, legacy must never be touched.
|
||||
const newId = generateKsuidId();
|
||||
const newEnv = await seedOrgProjectEnv(prisma17, "x2new");
|
||||
await seedWaitpoint(prisma17, newId, {
|
||||
id: newEnv.environment.id,
|
||||
projectId: newEnv.project.id,
|
||||
});
|
||||
const newLegacy = recording(prisma14, { forbidden: true });
|
||||
const migratedPresenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: newLegacy.handle,
|
||||
});
|
||||
const migratedResult = await migratedPresenter.call(
|
||||
environmentArg(newEnv.environment),
|
||||
newId
|
||||
);
|
||||
expect(migratedResult.id).toBe(`waitpoint_${newId}`);
|
||||
expect(newLegacy.calls.length).toBe(0);
|
||||
|
||||
// In-retention waitpoint: lives on the legacy replica, served from it.
|
||||
const oldId = generateLegacyCuid();
|
||||
const oldEnv = await seedOrgProjectEnv(prisma14, "x2old");
|
||||
await seedWaitpoint(prisma14, oldId, {
|
||||
id: oldEnv.environment.id,
|
||||
projectId: oldEnv.project.id,
|
||||
});
|
||||
const retentionPresenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
});
|
||||
const retentionResult = await retentionPresenter.call(
|
||||
environmentArg(oldEnv.environment),
|
||||
oldId
|
||||
);
|
||||
expect(retentionResult.id).toBe(`waitpoint_${oldId}`);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: the split-mode NEW client is the REAL scalar-only run-ops client (prisma17). A cuid
|
||||
// classifies LEGACY, so readThroughRun probes NEW first — a relation in hydrate() (connectedRuns)
|
||||
// throws PrismaClientValidationError there (the 500) before the legacy fallback runs.
|
||||
describe("ApiWaitpointPresenter read-through (dedicated scalar-only run-ops NEW client)", () => {
|
||||
heteroRunOpsPostgresTest(
|
||||
"cuid token: hydrate() select is valid against the scalar-only run-ops client, resolves via legacy",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateLegacyCuid();
|
||||
expect(id.length).toBe(25);
|
||||
|
||||
const { project, environment } = await seedOrgProjectEnv(prisma14, "scalar-legacy");
|
||||
const seeded = await seedWaitpoint(
|
||||
prisma14,
|
||||
id,
|
||||
{ id: environment.id, projectId: project.id },
|
||||
{ tags: ["p", "q"], output: JSON.stringify({ ok: true }) }
|
||||
);
|
||||
|
||||
const newClient = recording(prisma17);
|
||||
const legacy = recording(prisma14);
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
|
||||
// Must NOT throw PrismaClientValidationError; resolves the token off the legacy side.
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
expect(result.id).toBe(seeded.friendlyId);
|
||||
expect(result.tags).toEqual(["p", "q"]);
|
||||
expect(result.output).toBe(JSON.stringify({ ok: true }));
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("ApiWaitpointPresenter passthrough (single-DB)", () => {
|
||||
postgresTest(
|
||||
"no read-through deps → one plain replica read; legacy never touched",
|
||||
async ({ prisma }) => {
|
||||
const id = generateKsuidId();
|
||||
const { project, environment } = await seedOrgProjectEnv(prisma, "pt");
|
||||
const seeded = await seedWaitpoint(
|
||||
prisma,
|
||||
id,
|
||||
{ id: environment.id, projectId: project.id },
|
||||
{ tags: ["one"], output: JSON.stringify({ ok: true }) }
|
||||
);
|
||||
|
||||
const single = recording(prisma);
|
||||
const legacy = recording(prisma, { forbidden: true });
|
||||
|
||||
// No splitEnabled → passthrough. newClient defaults to the single recording handle so we
|
||||
// can assert exactly one read against it; legacy must never fire.
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
newClient: single.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
expect(result.id).toBe(seeded.friendlyId);
|
||||
expect(result.tags).toEqual(["one"]);
|
||||
expect(result.output).toBe(JSON.stringify({ ok: true }));
|
||||
// Passthrough: exactly one read on the single client; legacy untouched.
|
||||
expect(single.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// BatchListPresenter imports `~/db.server` (for `sqlDatabaseSchema` + `PrismaClientOrTransaction`),
|
||||
// `~/models/runtimeEnvironment.server`, and `~/components/*` at load — all of which pull
|
||||
// `env.server` at import time. Stub `~/db.server` to break that chain (the runsRepository
|
||||
// read-through test does the same). The presenter is driven entirely through injected real
|
||||
// containers; the only thing it actually reads off this module is `sqlDatabaseSchema`, which we
|
||||
// reproduce as the real `Prisma.sql(["public"])` value so the schema-qualified raw scan SQL is valid.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
sqlDatabaseSchema: Prisma.sql(["public"]),
|
||||
}));
|
||||
|
||||
import {
|
||||
heteroPostgresTest,
|
||||
heteroRunOpsPostgresTest,
|
||||
postgresTest,
|
||||
} from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
type BatchListOptions,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
// The exact presenter scan SQL, lifted verbatim, so tests can compare the presenter's output
|
||||
// against a direct $queryRaw of the identical SQL on each DB version.
|
||||
function rawScan(
|
||||
prisma: PrismaClient,
|
||||
opts: {
|
||||
environmentId: string;
|
||||
pageSize: number;
|
||||
direction: "forward" | "backward";
|
||||
cursor?: string;
|
||||
}
|
||||
) {
|
||||
const { environmentId, pageSize, direction, cursor } = opts;
|
||||
const sqlDatabaseSchema = Prisma.sql(["public"]);
|
||||
return prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
status: any;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
completedAt: Date | null;
|
||||
runCount: bigint;
|
||||
batchVersion: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
b.id,
|
||||
b."friendlyId",
|
||||
b."runtimeEnvironmentId",
|
||||
b.status,
|
||||
b."createdAt",
|
||||
b."updatedAt",
|
||||
b."completedAt",
|
||||
b."runCount",
|
||||
b."batchVersion"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BatchTaskRun" b
|
||||
WHERE
|
||||
b."runtimeEnvironmentId" = ${environmentId}
|
||||
${
|
||||
cursor
|
||||
? direction === "forward"
|
||||
? Prisma.sql`AND b.id < ${cursor}`
|
||||
: Prisma.sql`AND b.id > ${cursor}`
|
||||
: Prisma.empty
|
||||
}
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
}
|
||||
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `user-${slug}@example.com`,
|
||||
name: `User ${slug}`,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
},
|
||||
});
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const orgMember = await prisma.orgMember.create({
|
||||
data: { organizationId: organization.id, userId: user.id },
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
orgMemberId: orgMember.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the org/project/env parents onto a second DB with the SAME ids (BatchTaskRun FK needs
|
||||
// the runtimeEnvironment to exist on whichever DB the row lives on).
|
||||
async function mirrorEnvParents(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
slug: string
|
||||
): Promise<void> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}_m`,
|
||||
pkApiKey: `pk_dev_${slug}_m`,
|
||||
shortcode: `sc-${slug}-m`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createBatch(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
batch: {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
status?: any;
|
||||
batchVersion?: string;
|
||||
runCount?: number;
|
||||
createdAt?: Date;
|
||||
}
|
||||
) {
|
||||
return prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
status: batch.status ?? "PENDING",
|
||||
batchVersion: batch.batchVersion ?? "v3",
|
||||
runCount: batch.runCount ?? 1,
|
||||
...(batch.createdAt ? { createdAt: batch.createdAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const baseCall = (
|
||||
ctx: SeedContext,
|
||||
overrides: Partial<BatchListOptions> = {}
|
||||
): BatchListOptions => ({
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
userId: ctx.userId,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Wraps a prisma client so the test can assert whether/how often batchTaskRun.findMany or
|
||||
// batchTaskRun.findFirst are invoked. Optionally throws if invoked (proves a handle is never touched).
|
||||
function spyClient(
|
||||
prisma: PrismaClient,
|
||||
opts: { throwOnQueryRaw?: boolean; throwOnFindFirst?: boolean } = {}
|
||||
) {
|
||||
const counts = { queryRaw: 0, findMany: 0, findFirst: 0 };
|
||||
const proxy = new Proxy(prisma, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "batchTaskRun") {
|
||||
const real = (target as any).batchTaskRun;
|
||||
return new Proxy(real, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findMany") {
|
||||
return (...args: any[]) => {
|
||||
counts.findMany++;
|
||||
if (opts.throwOnQueryRaw)
|
||||
throw new Error("batchTaskRun.findMany must not be invoked on this handle");
|
||||
return (trTarget as any).findMany(...args);
|
||||
};
|
||||
}
|
||||
if (trProp === "findFirst") {
|
||||
return (...args: any[]) => {
|
||||
counts.findFirst++;
|
||||
if (opts.throwOnFindFirst)
|
||||
throw new Error("batchTaskRun.findFirst must not be invoked on this handle");
|
||||
return (trTarget as any).findFirst(...args);
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
return { client: proxy, counts };
|
||||
}
|
||||
|
||||
const desc = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
|
||||
|
||||
describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + PG17 new)", () => {
|
||||
// Byte-identical scan + identical ORDER-BY across PG14/PG17.
|
||||
heteroPostgresTest(
|
||||
"raw paginated scan is byte-identical and identically ordered across PG14 and PG17 (both directions, with/without cursor)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx14 = await seedParents(prisma14, "scan");
|
||||
const ctx17 = await seedParents(prisma17, "scan");
|
||||
|
||||
// Identical corpus on both sides (same logical ids), exercising statuses + batchVersion +
|
||||
// createdAt spanning a period, and keyset cursor boundaries.
|
||||
const ids = ["aaaa", "bbbb", "cccc", "dddd", "eeee"];
|
||||
const statuses = ["PENDING", "COMPLETED", "ABORTED", "PROCESSING", "COMPLETED"];
|
||||
const versions = ["v3", "v3", "v1", "v2", "v3"];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await createBatch(prisma14, ctx14, {
|
||||
id: `batch_${ids[i]}`,
|
||||
friendlyId: `fr_${ids[i]}`,
|
||||
status: statuses[i],
|
||||
batchVersion: versions[i],
|
||||
runCount: i + 1,
|
||||
createdAt: new Date(Date.now() - i * 60_000),
|
||||
});
|
||||
await createBatch(prisma17, ctx17, {
|
||||
id: `batch_${ids[i]}`,
|
||||
friendlyId: `fr_${ids[i]}`,
|
||||
status: statuses[i],
|
||||
batchVersion: versions[i],
|
||||
runCount: i + 1,
|
||||
createdAt: new Date(Date.now() - i * 60_000),
|
||||
});
|
||||
}
|
||||
|
||||
for (const direction of ["forward", "backward"] as const) {
|
||||
for (const cursor of [undefined, "batch_cccc"]) {
|
||||
const rows14 = await rawScan(prisma14, {
|
||||
environmentId: ctx14.environmentId,
|
||||
pageSize: 2,
|
||||
direction,
|
||||
cursor,
|
||||
});
|
||||
const rows17 = await rawScan(prisma17, {
|
||||
environmentId: ctx17.environmentId,
|
||||
pageSize: 2,
|
||||
direction,
|
||||
cursor,
|
||||
});
|
||||
// ids are identical across both DBs; rows must match byte-for-byte and in order.
|
||||
expect(rows14.map((r) => r.id)).toEqual(rows17.map((r) => r.id));
|
||||
expect(rows14.map((r) => r.friendlyId)).toEqual(rows17.map((r) => r.friendlyId));
|
||||
expect(rows14.map((r) => r.runCount)).toEqual(rows17.map((r) => r.runCount));
|
||||
expect(rows14.map((r) => r.status)).toEqual(rows17.map((r) => r.status));
|
||||
// ORDER-BY parity: forward => id DESC, backward => id ASC.
|
||||
const order = rows14.map((r) => r.id);
|
||||
const expected = [...order].sort(direction === "forward" ? desc : (a, b) => -desc(a, b));
|
||||
expect(order).toEqual(expected);
|
||||
}
|
||||
}
|
||||
|
||||
// The TS codepoint comparator reproduces the DB ORDER BY over the seeded id set.
|
||||
const allIds = ids.map((i) => `batch_${i}`);
|
||||
const dbForward = (
|
||||
await rawScan(prisma17, {
|
||||
environmentId: ctx17.environmentId,
|
||||
pageSize: 50,
|
||||
direction: "forward",
|
||||
})
|
||||
).map((r) => r.id);
|
||||
expect(dbForward).toEqual([...allIds].sort(desc));
|
||||
}
|
||||
);
|
||||
|
||||
// Split scan merge serves new + legacy in one keyset-ordered page.
|
||||
heteroPostgresTest(
|
||||
"split scan merges new (PG17) + legacy (PG14) rows under the keyset order; legacy read only when new does not fill the page",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx14 = await seedParents(prisma14, "merge");
|
||||
await mirrorEnvParents(prisma17, ctx14, "merge");
|
||||
|
||||
// Interleaved ids across the keyset order. New (migrated) on PG17, legacy on PG14.
|
||||
await createBatch(prisma17, ctx14, { id: "batch_a", friendlyId: "fr_a", runCount: 1 });
|
||||
await createBatch(prisma14, ctx14, { id: "batch_b", friendlyId: "fr_b", runCount: 2 });
|
||||
await createBatch(prisma17, ctx14, { id: "batch_c", friendlyId: "fr_c", runCount: 3 });
|
||||
await createBatch(prisma14, ctx14, { id: "batch_d", friendlyId: "fr_d", runCount: 4 });
|
||||
await createBatch(prisma17, ctx14, { id: "batch_e", friendlyId: "fr_e", runCount: 5 });
|
||||
|
||||
// Case A: small page fully served by new alone => legacy NOT read.
|
||||
const legacySpyA = spyClient(prisma14);
|
||||
const presenterA = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: legacySpyA.client,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const pageA = await presenterA.call(baseCall(ctx14, { pageSize: 2 }));
|
||||
// new ids are e, c, a -> DESC: e, c (pageSize 2). pageSize+1 = 3 rows from new fills the page.
|
||||
expect(pageA.batches.map((b) => b.id)).toEqual(["batch_e", "batch_c"]);
|
||||
expect(legacySpyA.counts.findMany).toBe(0);
|
||||
|
||||
// Case B: page needs legacy rows => legacy IS read and the merge is keyset-ordered union.
|
||||
const legacySpyB = spyClient(prisma14);
|
||||
const presenterB = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: legacySpyB.client,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const pageB = await presenterB.call(baseCall(ctx14, { pageSize: 4 }));
|
||||
// union DESC of all 5: e, d, c, b, a -> first 4.
|
||||
expect(pageB.batches.map((b) => b.id)).toEqual(["batch_e", "batch_d", "batch_c", "batch_b"]);
|
||||
expect(legacySpyB.counts.findMany).toBeGreaterThan(0);
|
||||
// cursor parity: next is the 4th id (pageSize-th), previous undefined (no input cursor).
|
||||
expect(pageB.pagination.next).toBe("batch_b");
|
||||
expect(pageB.pagination.previous).toBeUndefined();
|
||||
expect(pageB.hasAnyBatches).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// Project resolves on control-plane; no cross-seam join.
|
||||
heteroPostgresTest(
|
||||
"project resolves on the control-plane handle (PG14); BatchTaskRun scan reads run-ops only",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
// Project/env/orgMember/user only on PG14 (control-plane). BatchTaskRun env mirrored to PG17.
|
||||
const ctx = await seedParents(prisma14, "cp");
|
||||
await mirrorEnvParents(prisma17, ctx, "cp");
|
||||
await createBatch(prisma17, ctx, { id: "batch_cp1", friendlyId: "fr_cp1", runCount: 7 });
|
||||
|
||||
// controlPlaneReplica must never run the BatchTaskRun raw scan.
|
||||
const cpSpy = spyClient(prisma14, { throwOnQueryRaw: true });
|
||||
// runOpsNew must never run a project lookup — guard by making project absent on PG17.
|
||||
const presenter = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
controlPlaneReplica: cpSpy.client,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
|
||||
expect(page.batches.map((b) => b.id)).toEqual(["batch_cp1"]);
|
||||
// displayableEnvironment mapped by in-memory id match.
|
||||
expect(page.batches[0].environment.id).toBe(ctx.environmentId);
|
||||
expect(page.batches[0].environment.type).toBe("DEVELOPMENT");
|
||||
// control-plane handle was used (project read) but never for the batch scan.
|
||||
expect(cpSpy.counts.findMany).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
// Empty-state probe is dual-DB during the window.
|
||||
heteroPostgresTest(
|
||||
"empty-state probe reads new then legacy replica: true when legacy has a batch, false when both empty",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "probe");
|
||||
await mirrorEnvParents(prisma17, ctx, "probe");
|
||||
|
||||
// Zero batches on new (PG17), one on legacy (PG14). A filter that yields an empty page.
|
||||
await createBatch(prisma14, ctx, { id: "batch_legacy_only", friendlyId: "fr_legacy_only" });
|
||||
|
||||
const presenter = new BatchListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
controlPlaneReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
// friendlyId filter that matches nothing => empty page, probe must still find the legacy row.
|
||||
const page = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
|
||||
expect(page.batches).toHaveLength(0);
|
||||
expect(page.hasAnyBatches).toBe(true);
|
||||
|
||||
// Now wipe legacy too => both empty => hasAnyBatches false.
|
||||
await prisma14.batchTaskRun.deleteMany({
|
||||
where: { runtimeEnvironmentId: ctx.environmentId },
|
||||
});
|
||||
const page2 = await presenter.call(baseCall(ctx, { friendlyId: "fr_does_not_exist" }));
|
||||
expect(page2.batches).toHaveLength(0);
|
||||
expect(page2.hasAnyBatches).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
// Single-DB passthrough collapses to one handle.
|
||||
postgresTest(
|
||||
"passthrough (no readRoute): scan + probe + project all read the single handle; legacy closures never invoked",
|
||||
async ({ prisma }) => {
|
||||
const ctx = await seedParents(prisma, "pass");
|
||||
await createBatch(prisma, ctx, { id: "batch_p1", friendlyId: "fr_p1", runCount: 3 });
|
||||
await createBatch(prisma, ctx, { id: "batch_p2", friendlyId: "fr_p2", runCount: 4 });
|
||||
await createBatch(prisma, ctx, { id: "batch_p3", friendlyId: "fr_p3", runCount: 5 });
|
||||
|
||||
const presenter = new BatchListPresenter(prisma, prisma);
|
||||
const page = await presenter.call(baseCall(ctx, { pageSize: 2 }));
|
||||
|
||||
// Page content + ordering + cursors equal a direct $queryRaw of the same SQL.
|
||||
const direct = await rawScan(prisma, {
|
||||
environmentId: ctx.environmentId,
|
||||
pageSize: 2,
|
||||
direction: "forward",
|
||||
});
|
||||
const hasMore = direct.length > 2;
|
||||
const expectedPage = direct.slice(0, 2);
|
||||
expect(page.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
|
||||
expect(page.pagination.next).toBe(hasMore ? expectedPage[1].id : undefined);
|
||||
expect(page.pagination.previous).toBeUndefined();
|
||||
expect(page.hasAnyBatches).toBe(true);
|
||||
|
||||
// A throwing legacy handle proves the split branch is never entered in passthrough.
|
||||
const throwingLegacy = spyClient(prisma, { throwOnQueryRaw: true, throwOnFindFirst: true });
|
||||
const presenterWithUnusedLegacy = new BatchListPresenter(prisma, prisma, {
|
||||
runOpsLegacyReplica: throwingLegacy.client,
|
||||
// splitEnabled omitted => passthrough; legacy must never be touched.
|
||||
});
|
||||
const page2 = await presenterWithUnusedLegacy.call(baseCall(ctx, { pageSize: 2 }));
|
||||
expect(page2.batches.map((b) => b.id)).toEqual(expectedPage.map((r) => r.id));
|
||||
expect(throwingLegacy.counts.findMany).toBe(0);
|
||||
expect(throwingLegacy.counts.findFirst).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "rawscan-batch14");
|
||||
|
||||
// runtimeEnvironmentId is a FK-free scalar in the run-ops schema — no parent row needed.
|
||||
await (prisma17 as RunOpsPrismaClient).batchTaskRun.create({
|
||||
data: {
|
||||
id: "rbatch00000000000000001",
|
||||
friendlyId: "fr_rbatch00000000000000001",
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
status: "PENDING",
|
||||
batchVersion: "v3",
|
||||
runCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new BatchListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
controlPlaneReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const page = await presenter.call(baseCall(ctx, { pageSize: 10 }));
|
||||
expect(page.batches.map((b) => b.id)).toContain("rbatch00000000000000001");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import {
|
||||
displayableEnvironment,
|
||||
type DisplayableInputEnvironment,
|
||||
} from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds the control-plane org/project/env on one DB. The env is control-plane; it is
|
||||
* resolved separately from the run-ops batch row (the cross-seam FK is physically dropped),
|
||||
* so this always lives on the same DB that the injected resolveDisplayableEnvironment reads.
|
||||
*/
|
||||
async function seedEnvironment(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
type: "DEVELOPMENT" | "PRODUCTION" = "PRODUCTION"
|
||||
): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
let orgMemberId: string | undefined;
|
||||
if (type === "DEVELOPMENT") {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `user-${slug}@example.com`,
|
||||
name: `User ${slug}`,
|
||||
displayName: `Display ${slug}`,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
},
|
||||
});
|
||||
const member = await prisma.orgMember.create({
|
||||
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
|
||||
});
|
||||
orgMemberId = member.id;
|
||||
}
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_${slug}`,
|
||||
pkApiKey: `pk_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
orgMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedBatch(
|
||||
prisma: PrismaClient,
|
||||
environmentId: string,
|
||||
opts: {
|
||||
friendlyId: string;
|
||||
status?: any;
|
||||
batchVersion?: string;
|
||||
runCount?: number;
|
||||
withError?: boolean;
|
||||
}
|
||||
) {
|
||||
return prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: opts.friendlyId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
status: opts.status ?? "COMPLETED",
|
||||
batchVersion: opts.batchVersion ?? "v1",
|
||||
runCount: opts.runCount ?? 0,
|
||||
successfulRunCount: 3,
|
||||
failedRunCount: 1,
|
||||
idempotencyKey: `idem-${opts.friendlyId}`,
|
||||
errors: opts.withError
|
||||
? {
|
||||
create: [
|
||||
{
|
||||
index: 0,
|
||||
taskIdentifier: "my-task",
|
||||
error: JSON.stringify({ message: "boom", stack: "x\ny" }),
|
||||
errorCode: "TASK_RUN_FAILED",
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a resolveDisplayableEnvironment closure over a real control-plane container — exactly
|
||||
* mirroring the production findDisplayableEnvironment (reads control-plane, returns the
|
||||
* displayableEnvironment shape). This is the only injected boundary; the DB is never mocked.
|
||||
*/
|
||||
function makeEnvResolver(controlPlane: PrismaClient) {
|
||||
return async (environmentId: string, userId: string | undefined) => {
|
||||
const environment = await controlPlane.runtimeEnvironment.findFirst({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: { user: { select: { id: true, name: true, displayName: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!environment) {
|
||||
return undefined;
|
||||
}
|
||||
return displayableEnvironment(environment as DisplayableInputEnvironment, userId);
|
||||
};
|
||||
}
|
||||
|
||||
describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
|
||||
// Batch detail resolves on run-ops NEW (split on). Legacy replica is never probed.
|
||||
heteroPostgresTest(
|
||||
"resolves a NEW-resident batch and never probes the legacy replica",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedEnvironment(prisma17, "new1");
|
||||
await seedBatch(prisma17, ctx.environmentId, {
|
||||
friendlyId: "batch_new1",
|
||||
withError: true,
|
||||
runCount: 4,
|
||||
});
|
||||
|
||||
// Real readThroughRun. A tripwire legacy client throws if batchTaskRun is ever accessed,
|
||||
// proving a NEW-resident row is served without probing the legacy replica.
|
||||
const tripwireLegacy = new Proxy(prisma14, {
|
||||
get(target, prop) {
|
||||
if (prop === "batchTaskRun") {
|
||||
throw new Error("legacy replica must not be probed for a NEW-resident batch");
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: tripwireLegacy,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
|
||||
});
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
batchId: "batch_new1",
|
||||
});
|
||||
|
||||
expect(result.friendlyId).toBe("batch_new1");
|
||||
expect(result.runCount).toBe(4);
|
||||
expect(result.idempotencyKey).toBe("idem-batch_new1");
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].errorCode).toBe("TASK_RUN_FAILED");
|
||||
expect(result.environment.id).toBe(ctx.environmentId);
|
||||
expect(result.environment.type).toBe("PRODUCTION");
|
||||
}
|
||||
);
|
||||
|
||||
// Batch detail resolves on run-ops OLD/legacy READ REPLICA (split on, in-retention).
|
||||
// Cross-version round-trip: PG14 legacy -> presenter, JSON error payload byte-identical.
|
||||
heteroPostgresTest(
|
||||
"resolves a legacy-only batch via the legacy READ REPLICA, byte-identical",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
// Env is control-plane (lives wherever the resolver reads); batch is run-ops, legacy-only.
|
||||
const ctx = await seedEnvironment(prisma14, "legacy1");
|
||||
const errorPayload = JSON.stringify({ message: "legacy boom", stack: "a\nb\nc" });
|
||||
await prisma14.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: "batch_legacy1",
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
status: "COMPLETED",
|
||||
batchVersion: "v1",
|
||||
runCount: 2,
|
||||
successfulRunCount: 1,
|
||||
failedRunCount: 1,
|
||||
idempotencyKey: "idem-batch_legacy1",
|
||||
errors: {
|
||||
create: [
|
||||
{
|
||||
index: 0,
|
||||
taskIdentifier: "legacy-task",
|
||||
error: errorPayload,
|
||||
errorCode: "LEGACY_CODE",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The structural guarantee: there is no legacy-PRIMARY handle in readThroughRun; the only
|
||||
// legacy handle threaded here is the read replica (prisma14).
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17, // NEW probe misses (nothing seeded there)
|
||||
legacyReplica: prisma14,
|
||||
// Real readThroughRun; the NEW miss falls through to the legacy replica.
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
|
||||
});
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
batchId: "batch_legacy1",
|
||||
});
|
||||
|
||||
expect(result.friendlyId).toBe("batch_legacy1");
|
||||
expect(result.runCount).toBe(2);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
// JSON error payload round-trips byte-identically across PG14 -> presenter.
|
||||
expect(result.errors[0].error).toBe(errorPayload);
|
||||
expect(result.errors[0].taskIdentifier).toBe("legacy-task");
|
||||
expect(result.environment.id).toBe(ctx.environmentId);
|
||||
}
|
||||
);
|
||||
|
||||
// Post-termination / not-found yields the normal "Batch not found".
|
||||
heteroPostgresTest(
|
||||
"throws the normal not-found when the batch is absent from both stores",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedEnvironment(prisma14, "missing1");
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: prisma14,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
|
||||
});
|
||||
|
||||
await expect(
|
||||
presenter.call({ environmentId: ctx.environmentId, batchId: "batch_does_not_exist" })
|
||||
).rejects.toThrow("Batch not found");
|
||||
}
|
||||
);
|
||||
|
||||
// Env decoupling parity for a DEVELOPMENT env (userName branch).
|
||||
heteroPostgresTest(
|
||||
"resolves the DEVELOPMENT env userName separately from the run-ops batch row",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedEnvironment(prisma17, "dev1", "DEVELOPMENT");
|
||||
await seedBatch(prisma17, ctx.environmentId, { friendlyId: "batch_dev1" });
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: prisma14,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
|
||||
});
|
||||
|
||||
// No userId passed -> userName resolves to the member's username (the orgMember branch).
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
batchId: "batch_dev1",
|
||||
});
|
||||
|
||||
expect(result.environment.type).toBe("DEVELOPMENT");
|
||||
expect(result.environment.userName).toBe("Display dev1");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("BatchPresenter single-DB passthrough", () => {
|
||||
// Passthrough + self-host collapse: one plain read, legacy closure never invoked.
|
||||
containerTest(
|
||||
"single-DB resolves the batch with one plain read and never touches the legacy boundary",
|
||||
async ({ prisma }) => {
|
||||
const ctx = await seedEnvironment(prisma, "passthrough");
|
||||
await seedBatch(prisma, ctx.environmentId, {
|
||||
friendlyId: "batch_passthrough",
|
||||
withError: true,
|
||||
runCount: 5,
|
||||
});
|
||||
|
||||
let legacyInvoked = false;
|
||||
const presenter = new BatchPresenter(prisma, prisma, {
|
||||
splitEnabled: false,
|
||||
// Pass the single DB as both clients; the passthrough must read NEW only.
|
||||
newClient: prisma,
|
||||
legacyReplica: new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "batchTaskRun") {
|
||||
legacyInvoked = true;
|
||||
throw new Error("legacy boundary must not be touched in single-DB passthrough");
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma),
|
||||
});
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
batchId: "batch_passthrough",
|
||||
});
|
||||
|
||||
expect(result.friendlyId).toBe("batch_passthrough");
|
||||
expect(result.runCount).toBe(5);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.environment.id).toBe(ctx.environmentId);
|
||||
expect(legacyInvoked).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
// e2e #3 scoped proxy: a batch whose members span migrated + abandoned runs still resolves
|
||||
// at the batch-record level. Scope: BatchPresenter reads only the batch row, not its member
|
||||
// TaskRuns — the dangling-reference gate over members is owned by the migration / dangling-gate
|
||||
// units, not this presenter. This unit's contribution is "batch detail loads regardless of
|
||||
// which run-ops store holds the batch row."
|
||||
containerTest(
|
||||
"e2e #3 proxy: a batch spanning migrated + abandoned runs still resolves",
|
||||
async ({ prisma }) => {
|
||||
const ctx = await seedEnvironment(prisma, "e2e3");
|
||||
await seedBatch(prisma, ctx.environmentId, {
|
||||
friendlyId: "batch_e2e3",
|
||||
runCount: 10, // implies members spanning migrated + abandoned runs
|
||||
});
|
||||
|
||||
const presenter = new BatchPresenter(prisma, prisma, {
|
||||
splitEnabled: false,
|
||||
newClient: prisma,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma),
|
||||
});
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
batchId: "batch_e2e3",
|
||||
});
|
||||
|
||||
expect(result.friendlyId).toBe("batch_e2e3");
|
||||
expect(result.runCount).toBe(10);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter graph imports `~/v3/runStore.server` (via RunsRepository) which imports
|
||||
// `~/db.server` at load, and the presenter itself reaches `~/db.server`'s `$replica` singleton
|
||||
// through `findDisplayableEnvironment` and `getTaskIdentifiers`. Stub the module so those
|
||||
// singleton reads resolve. This is the ONLY mock — the DB is NEVER mocked; the `$replica`
|
||||
// stub delegates to the per-test REAL legacy container so the env-lookup + task-identifier
|
||||
// reads hit a real database. Everything asserted runs against real containers.
|
||||
//
|
||||
// `legacyReplicaHolder.client` is set by each test to its real legacy `prisma` handle before
|
||||
// calling the presenter; the proxy forwards every property access to it lazily. Created via
|
||||
// vi.hoisted so it exists when the hoisted vi.mock factory runs.
|
||||
// `legacyReplicaHolder.client` -> the legacy handle backing the `prisma`/`$replica`
|
||||
// singletons; `newClientHolder.client` -> the new handle backing `runOpsNewPrisma`
|
||||
// (used by the routed store's default known-migrated probe). Each test sets both before calling.
|
||||
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
const lazyProxy = (holder: { client: any }, label: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (!holder.client) {
|
||||
throw new Error(`${label} not set for this test`);
|
||||
}
|
||||
return holder.client[prop];
|
||||
},
|
||||
}
|
||||
);
|
||||
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
|
||||
const newProxy = lazyProxy(newClientHolder, "newClientHolder.client");
|
||||
return {
|
||||
prisma: replicaProxy,
|
||||
$replica: replicaProxy,
|
||||
runOpsNewPrisma: newProxy,
|
||||
runOpsNewReplica: newProxy,
|
||||
runOpsLegacyPrisma: replicaProxy,
|
||||
runOpsLegacyReplica: replicaProxy,
|
||||
sqlDatabaseSchema: Prisma.sql([`public`]),
|
||||
};
|
||||
});
|
||||
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { setupClickhouseReplication } from "./utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
|
||||
* exist on every DB a run lives on, so identical parents (same ids) are seeded on both the
|
||||
* legacy and new databases.
|
||||
*/
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_dev_${slug}_b`,
|
||||
pkApiKey: `pk_dev_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: { friendlyId: string; taskIdentifier?: string; status?: any; runTags?: string[] }
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: run.taskIdentifier ?? "my-task",
|
||||
status: run.status ?? "PENDING",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "test",
|
||||
runTags: run.runTags ?? [],
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a real prisma handle in a Proxy whose `taskRun.findFirst` throws if invoked. Used to
|
||||
* prove the empty-state probe never touches the legacy replica when the new DB already answers.
|
||||
* All other access forwards to the real client (so FK parents etc. still resolve).
|
||||
*/
|
||||
function throwingFindFirst(prisma: PrismaClient, label: string): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findFirst") {
|
||||
return async () => {
|
||||
throw new Error(`${label}.taskRun.findFirst must not be invoked`);
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
const callOptions = (ctx: SeedContext, overrides?: { to?: number }) => ({
|
||||
projectId: ctx.projectId,
|
||||
pageSize: 10,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// `to` one hour in the past. The CH page filters `created_at <= to`, so a just-created run is
|
||||
// deterministically excluded regardless of replication timing — the empty-state tests otherwise
|
||||
// raced on the run not having replicated yet (held locally, failed on CI). The PG existence probe
|
||||
// has no time filter, so it still finds the row and `hasAnyRuns` stays true.
|
||||
const emptyPageWindow = (): { to: number } => ({ to: Date.now() - 60 * 60 * 1000 });
|
||||
|
||||
describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legacy + new Postgres)", () => {
|
||||
// no-false-empty. Runs ONLY on legacy, none on new. Empty CH page -> listRuns returns [].
|
||||
// splitEnabled true. The probe misses NEW, falls through to the legacy replica and finds the
|
||||
// row, so the dashboard must NOT show "no runs".
|
||||
replicationContainerTest(
|
||||
"no-false-empty: runs only on the legacy replica still report hasAnyRuns true",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "nofalseempty");
|
||||
await mirrorParents(prismaNew, ctx, "nofalseempty");
|
||||
|
||||
// Run lives ONLY on the legacy DB. We seed it to legacy and never wait for CH replication,
|
||||
// so within the page window the CH id-set page is empty and listRuns returns []. The
|
||||
// empty-state probe (NEW miss -> legacy hit) is what proves hasAnyRuns stays true.
|
||||
await createRun(prisma, ctx, { friendlyId: "run_legacyOnly" });
|
||||
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(
|
||||
ctx.organizationId,
|
||||
ctx.environmentId,
|
||||
callOptions(ctx, emptyPageWindow())
|
||||
);
|
||||
|
||||
// CH id-set is empty within the page window, but the legacy probe finds the row.
|
||||
expect(result.runs).toHaveLength(0);
|
||||
expect(result.hasAnyRuns).toBe(true);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// new-DB short-circuit. A run on NEW, legacy replica wrapped so its taskRun.findFirst
|
||||
// throws. Empty CH page. The probe answers from NEW and must NEVER fall through to legacy. The
|
||||
// post-migration straggler is the same shape: present on NEW, absent from LEGACY, legacy never
|
||||
// invoked.
|
||||
replicationContainerTest(
|
||||
"new-DB short-circuit: hasAnyRuns answered from the new DB without touching the legacy replica",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "newshortcircuit");
|
||||
await mirrorParents(prismaNew, ctx, "newshortcircuit");
|
||||
|
||||
// The (migrated/straggler) run lives ONLY on NEW.
|
||||
await createRun(prismaNew, ctx, { friendlyId: "run_newOnly" });
|
||||
|
||||
const legacySpy = throwingFindFirst(prisma, "legacyReplica");
|
||||
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: legacySpy,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
// If the legacy spy were invoked, this would throw — the test passing IS the proof.
|
||||
const result = await presenter.call(
|
||||
ctx.organizationId,
|
||||
ctx.environmentId,
|
||||
callOptions(ctx)
|
||||
);
|
||||
|
||||
expect(result.runs).toHaveLength(0);
|
||||
expect(result.hasAnyRuns).toBe(true);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// genuinely empty. Nothing on either DB. Empty CH page. splitEnabled true. Both
|
||||
// probes run and return null -> the true empty state is preserved.
|
||||
replicationContainerTest(
|
||||
"genuinely empty: both DBs empty reports hasAnyRuns false",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "trulyempty");
|
||||
await mirrorParents(prismaNew, ctx, "trulyempty");
|
||||
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(
|
||||
ctx.organizationId,
|
||||
ctx.environmentId,
|
||||
callOptions(ctx)
|
||||
);
|
||||
|
||||
expect(result.runs).toHaveLength(0);
|
||||
expect(result.hasAnyRuns).toBe(false);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// passthrough single-DB (two-arg ctor). One `prisma`, seed a run, empty CH page.
|
||||
// splitEnabled defaults false -> exactly one plain findFirst against the single handle; the
|
||||
// split branch (new/legacy) is structurally never entered (no second handle is injected).
|
||||
// Also covers "served from the replica only" — the ctor exposes no legacy-writer field, so a
|
||||
// no-primary-read guarantee is structural.
|
||||
replicationContainerTest(
|
||||
"passthrough single-DB: two-arg ctor finds the run via the single handle",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
legacyReplicaHolder.client = prisma;
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough");
|
||||
await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
|
||||
|
||||
// Two-arg ctor: no readThroughDeps -> RunsRepository.readThrough is undefined
|
||||
// (passthrough) and the probe is one plain `this.replica.taskRun.findFirst`.
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse);
|
||||
|
||||
const result = await presenter.call(
|
||||
ctx.organizationId,
|
||||
ctx.environmentId,
|
||||
callOptions(ctx, emptyPageWindow())
|
||||
);
|
||||
|
||||
expect(result.runs).toHaveLength(0);
|
||||
expect(result.hasAnyRuns).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// list hydrate flows through the routed store: split, non-empty CH id-set whose rows are
|
||||
// split across NEW + the legacy replica. result.runs must be the union, id-desc ordered. This
|
||||
// proves the deps are threaded so the routed store is actually used.
|
||||
// We assert the rows that DO surface (the full union, since legacy is probed for any id that
|
||||
// misses on NEW).
|
||||
// The migrated runs (run_newA/run_newB) live on BOTH DBs with the same id + friendlyId but a
|
||||
// DISTINGUISHING taskIdentifier: "my-task" on legacy, "my-task-NEW" on new. #hydrateRunsByIds
|
||||
// takes NEW rows first and only probes legacy for ids NOT on NEW, so a migrated row can only
|
||||
// carry "my-task-NEW" if it was served from the threaded newClient (new DB) — asserted below.
|
||||
replicationContainerTest(
|
||||
"list hydrate flows through the routed store: result.runs is the NEW + legacy union, id-desc",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
// The routed store's default known-migrated probe reads `runOpsNewPrisma` -> the new DB.
|
||||
newClientHolder.client = prismaNew;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "hydrate");
|
||||
await mirrorParents(prismaNew, ctx, "hydrate");
|
||||
|
||||
// All four runs land on the legacy DB (legacy + replication source -> CH gets the full id-set).
|
||||
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
|
||||
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
|
||||
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
|
||||
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
|
||||
|
||||
// The two "migrated" runs also live on NEW (authoritative during retention), same ids +
|
||||
// friendlyIds, but a DISTINGUISHING taskIdentifier so a row served from the new DB is
|
||||
// identifiable: "my-task-NEW" here vs the default "my-task" on the legacy DB.
|
||||
await createRun(prismaNew, ctx, { friendlyId: "run_newA", taskIdentifier: "my-task-NEW" });
|
||||
await createRun(prismaNew, ctx, { friendlyId: "run_newB", taskIdentifier: "my-task-NEW" });
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_newA" },
|
||||
data: { id: migratedA.id },
|
||||
});
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_newB" },
|
||||
data: { id: migratedB.id },
|
||||
});
|
||||
|
||||
// Wait for CH replication so the id-set page is non-empty.
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse, {
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(
|
||||
ctx.organizationId,
|
||||
ctx.environmentId,
|
||||
callOptions(ctx)
|
||||
);
|
||||
|
||||
const expectedIds = [migratedA.id, migratedB.id, legacyOnlyA.id, legacyOnlyB.id].sort(
|
||||
(a, b) => (a < b ? 1 : a > b ? -1 : 0)
|
||||
);
|
||||
expect(result.runs.map((r) => r.id)).toEqual(expectedIds);
|
||||
|
||||
// The migrated rows must carry the new-DB-only taskIdentifier — this can only hold if they
|
||||
// were hydrated from the threaded newClient (new DB), proving the routed store used it.
|
||||
expect(result.runs.find((r) => r.id === migratedA.id)?.friendlyId).toBe("run_newA");
|
||||
expect(result.runs.find((r) => r.id === migratedA.id)?.taskIdentifier).toBe("my-task-NEW");
|
||||
expect(result.runs.find((r) => r.id === migratedB.id)?.taskIdentifier).toBe("my-task-NEW");
|
||||
// The legacy-only rows surface from the legacy DB with the legacy taskIdentifier — proving the
|
||||
// legacyReplica (legacy DB) is also exercised for ids absent from the new DB.
|
||||
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.friendlyId).toBe("run_legacyA");
|
||||
expect(result.runs.find((r) => r.id === legacyOnlyA.id)?.taskIdentifier).toBe("my-task");
|
||||
expect(result.runs.find((r) => r.id === legacyOnlyB.id)?.taskIdentifier).toBe("my-task");
|
||||
|
||||
// Non-empty page -> the empty-state probe is not consulted, but it's still true.
|
||||
expect(result.hasAnyRuns).toBe(true);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
// Run-ops split resolution LOCK for ApiBatchResultsPresenter.
|
||||
//
|
||||
// GET /api/v1/batches/:id/results constructs the presenter BARE (no injected client), so it must
|
||||
// resolve a batch that lives in the NEW run-ops DB on its own. The presenter routes the batch-row
|
||||
// lookup through the `runStore` singleton, whose split router probes NEW→LEGACY. This drives a
|
||||
// NEW-resident (ksuid) batch through a REAL two-physical-DB split router and asserts the bare
|
||||
// presenter finds it. Fails before the fix (the presenter read the control-plane DB directly and
|
||||
// 404'd on a NEW-resident batch).
|
||||
|
||||
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import type { Organization, PrismaClient, Project } from "@trigger.dev/database";
|
||||
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { expect, vi } from "vitest";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
|
||||
// The split router built over the two testcontainer DBs; injected in place of the db.server-backed
|
||||
// singleton the presenter imports. Populated per-test before the presenter is constructed.
|
||||
let testRunStore: RoutingRunStore;
|
||||
|
||||
// Presenter reads the batch row via `runStore`; child-run reads also go through it. Neutralize the
|
||||
// real db.server singleton (no env DB) and the runStore singleton (use the split router below).
|
||||
// The getter defers to `testRunStore` so each test can set its own router before constructing.
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
vi.mock("~/v3/runStore.server", () => ({
|
||||
get runStore() {
|
||||
return testRunStore;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
function makeSplitRouter(prisma14: PrismaClient, prisma17: RunOpsPrismaClient) {
|
||||
const legacyStore = new PostgresRunStore({
|
||||
prisma: prisma14,
|
||||
readOnlyPrisma: prisma14,
|
||||
schemaVariant: "legacy",
|
||||
});
|
||||
const newStore = new PostgresRunStore({
|
||||
prisma: prisma17 as never,
|
||||
readOnlyPrisma: prisma17 as never,
|
||||
schemaVariant: "dedicated",
|
||||
});
|
||||
return new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
||||
}
|
||||
|
||||
function authEnv(environmentId: string): AuthenticatedEnvironment {
|
||||
return {
|
||||
id: environmentId,
|
||||
type: "DEVELOPMENT",
|
||||
project: { id: "proj_split" } as Project,
|
||||
organization: { id: "org_split" } as Organization,
|
||||
orgMember: null,
|
||||
} as unknown as AuthenticatedEnvironment;
|
||||
}
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"a bare ApiBatchResultsPresenter resolves a NEW-resident (ksuid) batch under the split",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
testRunStore = makeSplitRouter(prisma14, prisma17);
|
||||
|
||||
const environmentId = "env_split_res";
|
||||
// ksuid internal id → classifies to the NEW store, seeded in the NEW (prisma17) DB. The
|
||||
// friendlyId probe fans out NEW→LEGACY regardless of id shape, so the NEW seed is what matters.
|
||||
const batchInternalId = generateKsuidId();
|
||||
const batchFriendlyId = `batch_${generateKsuidId()}`;
|
||||
|
||||
await prisma17.batchTaskRun.create({
|
||||
data: {
|
||||
id: batchInternalId,
|
||||
friendlyId: batchFriendlyId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Bare construction — exactly how the results route builds it.
|
||||
const presenter = new ApiBatchResultsPresenter();
|
||||
const result = await presenter.call(batchFriendlyId, authEnv(environmentId));
|
||||
|
||||
// Before the fix this 404s (undefined) because a control-plane read misses the NEW-resident batch.
|
||||
expect(result).toEqual({ id: batchFriendlyId, items: [] });
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"a bare ApiBatchResultsPresenter still returns undefined for a genuinely missing batch",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
testRunStore = makeSplitRouter(prisma14, prisma17);
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter();
|
||||
const result = await presenter.call("batch_does_not_exist", authEnv("env_none"));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
@@ -177,7 +177,9 @@ containerTest(
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(prisma);
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(
|
||||
batchFriendlyId,
|
||||
authEnv(environment, project, organization)
|
||||
@@ -243,7 +245,9 @@ containerTest(
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(prisma);
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(
|
||||
batchFriendlyId,
|
||||
authEnv(environment, project, organization)
|
||||
@@ -267,7 +271,9 @@ containerTest("ApiBatchResultsPresenter short-circuits an empty batch", async ({
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new ApiBatchResultsPresenter(prisma);
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(batchFriendlyId, authEnv(environment, project, organization));
|
||||
|
||||
expect(result).toEqual({ id: batchFriendlyId, items: [] });
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPLIT-NEUTRAL VERIFICATION: getActivity reads ClickHouse
|
||||
// (task_runs_v2) ONLY and never touches the run-ops Prisma client. A
|
||||
// heterogeneous legacy/new Postgres fixture is deliberately not applicable
|
||||
// here — there is no run-ops Postgres read to validate cross-version. The
|
||||
// throwing Proxy passed as `replica` proves it: any access throws.
|
||||
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { ClickHouse, type TaskRunV2 } from "@internal/clickhouse";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { TaskDetailPresenter } from "~/presenters/v3/TaskDetailPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const organizationId = "org_activity_test";
|
||||
const projectId = "project_activity_test";
|
||||
const environmentId = "env_activity_test";
|
||||
const taskSlug = "my-activity-task";
|
||||
|
||||
function makeRun(overrides: Partial<TaskRunV2>): TaskRunV2 {
|
||||
const createdAt = overrides.created_at ?? Date.now();
|
||||
return {
|
||||
environment_id: environmentId,
|
||||
organization_id: organizationId,
|
||||
project_id: projectId,
|
||||
run_id: `run_${randomUUID()}`,
|
||||
friendly_id: `friendly_${randomUUID()}`,
|
||||
updated_at: createdAt,
|
||||
created_at: createdAt,
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
environment_type: "PRODUCTION",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
task_identifier: taskSlug,
|
||||
queue: "my-queue",
|
||||
schedule_id: "",
|
||||
batch_id: "",
|
||||
task_version: "",
|
||||
sdk_version: "",
|
||||
cli_version: "",
|
||||
machine_preset: "",
|
||||
root_run_id: "",
|
||||
parent_run_id: "",
|
||||
span_id: "",
|
||||
trace_id: "",
|
||||
idempotency_key: "",
|
||||
expiration_ttl: "",
|
||||
_version: "1",
|
||||
_is_deleted: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => {
|
||||
clickhouseTest(
|
||||
"buckets task_runs_v2 activity by status group, excludes deleted, never reads Postgres",
|
||||
async ({ clickhouseContainer }) => {
|
||||
const clickhouse = new ClickHouse({
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
name: "task-detail-activity-test",
|
||||
compression: { request: true },
|
||||
});
|
||||
|
||||
const insert = clickhouse.writer.insert({
|
||||
name: "insertTaskRunsActivityTest",
|
||||
table: "trigger_dev.task_runs_v2",
|
||||
schema: z.any(),
|
||||
settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 },
|
||||
});
|
||||
|
||||
// 6h window => 300s (5-minute) buckets => 72 buckets (chooseBucketSeconds targets ~72).
|
||||
const from = new Date("2026-01-01T00:00:00Z");
|
||||
const to = new Date("2026-01-01T06:00:00Z");
|
||||
const BUCKET_MS = 5 * 60 * 1000;
|
||||
|
||||
// 00:30 bucket: 1 COMPLETED, 1 FAILED (+ 1 deleted, excluded).
|
||||
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING + unknown-status = 2.
|
||||
const bucket0 = new Date("2026-01-01T00:30:00Z").getTime();
|
||||
const bucket2 = new Date("2026-01-01T02:30:00Z").getTime();
|
||||
|
||||
const rows = [
|
||||
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY" }),
|
||||
makeRun({ created_at: bucket0, status: "CRASHED" }), // FAILED group
|
||||
makeRun({ created_at: bucket2, status: "CANCELED" }), // CANCELED group
|
||||
makeRun({ created_at: bucket2, status: "EXECUTING" }), // RUNNING group
|
||||
makeRun({ created_at: bucket2, status: "SOME_UNKNOWN_STATUS" }), // folds into RUNNING
|
||||
// Deleted row — distinct run, _is_deleted = 1, must NOT be counted.
|
||||
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY", _is_deleted: 1 }),
|
||||
];
|
||||
|
||||
const [insertError] = await insert(rows);
|
||||
expect(insertError).toBeNull();
|
||||
|
||||
const throwingReplica = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("getActivity must not touch the run-ops Prisma client");
|
||||
},
|
||||
}
|
||||
) as never;
|
||||
|
||||
const presenter = new TaskDetailPresenter(throwingReplica, clickhouse);
|
||||
|
||||
const activity = await presenter.getActivity({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
taskSlug,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
// Stable legend, fixed group order.
|
||||
expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]);
|
||||
|
||||
// 72 five-minute buckets, every bucket carries all four group keys.
|
||||
expect(activity.data).toHaveLength(72);
|
||||
for (const point of activity.data) {
|
||||
expect(typeof point.bucket).toBe("number");
|
||||
expect(point).toHaveProperty("COMPLETED");
|
||||
expect(point).toHaveProperty("FAILED");
|
||||
expect(point).toHaveProperty("CANCELED");
|
||||
expect(point).toHaveProperty("RUNNING");
|
||||
}
|
||||
|
||||
// Buckets are epoch MILLISECONDS aligned to the 5-minute interval.
|
||||
const byBucket = new Map(activity.data.map((p) => [p.bucket, p]));
|
||||
const p0 = byBucket.get(Math.floor(bucket0 / BUCKET_MS) * BUCKET_MS)!;
|
||||
const p2 = byBucket.get(Math.floor(bucket2 / BUCKET_MS) * BUCKET_MS)!;
|
||||
expect(p0).toBeDefined();
|
||||
expect(p2).toBeDefined();
|
||||
|
||||
// 00:30 bucket: 1 COMPLETED, 1 FAILED, deleted row excluded.
|
||||
expect(p0.COMPLETED).toBe(1);
|
||||
expect(p0.FAILED).toBe(1);
|
||||
expect(p0.CANCELED).toBe(0);
|
||||
expect(p0.RUNNING).toBe(0);
|
||||
|
||||
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2.
|
||||
expect(p2.COMPLETED).toBe(0);
|
||||
expect(p2.FAILED).toBe(0);
|
||||
expect(p2.CANCELED).toBe(1);
|
||||
expect(p2.RUNNING).toBe(2);
|
||||
|
||||
// Every other bucket is all-zero for every group.
|
||||
for (const point of activity.data) {
|
||||
if (point.bucket === p0.bucket || point.bucket === p2.bucket) continue;
|
||||
expect(point.COMPLETED).toBe(0);
|
||||
expect(point.FAILED).toBe(0);
|
||||
expect(point.CANCELED).toBe(0);
|
||||
expect(point.RUNNING).toBe(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
||||
// at load. Stub it (the sibling runsRepository.readthrough.test.ts does the same) — the
|
||||
// presenter under test is driven entirely through injected real containers, never the
|
||||
// stubbed module singletons.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import superjson from "superjson";
|
||||
import { TestTaskPresenter } from "~/presenters/v3/TestTaskPresenter.server";
|
||||
import { setupClickhouseReplication } from "../utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
const JSON_TYPE = "application/json";
|
||||
const SUPERJSON_TYPE = "application/super+json";
|
||||
|
||||
/**
|
||||
* Creates the org/project/(DEVELOPMENT) env parents plus the BackgroundWorker +
|
||||
* BackgroundWorkerTask the presenter resolves the task from. TaskRun FKs require
|
||||
* the org/project/env to exist on every DB a run is hydrated from.
|
||||
*/
|
||||
async function seedParents(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
triggerSource: "STANDARD" | "SCHEDULED"
|
||||
): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${slug}`,
|
||||
contentHash: `hash-${slug}`,
|
||||
version: "20240101.1",
|
||||
engine: "V2",
|
||||
metadata: {},
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
},
|
||||
});
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${slug}`,
|
||||
slug: "my-task",
|
||||
filePath: "src/trigger/my-task.ts",
|
||||
exportName: "myTask",
|
||||
workerId: worker.id,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
triggerSource,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_dev_${slug}_b`,
|
||||
pkApiKey: `pk_dev_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: {
|
||||
friendlyId: string;
|
||||
payload: string;
|
||||
payloadType?: string;
|
||||
createdAt?: Date;
|
||||
runTags?: string[];
|
||||
}
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
payload: run.payload,
|
||||
payloadType: run.payloadType ?? JSON_TYPE,
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "task/my-task",
|
||||
runTags: run.runTags ?? [],
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
...(run.createdAt ? { createdAt: run.createdAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy a row created on one DB onto another DB with the SAME id. */
|
||||
async function copyRunWithId(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
source: { id: string; friendlyId: string; payload: string; payloadType: string; createdAt: Date }
|
||||
) {
|
||||
const created = await createRun(prisma, ctx, {
|
||||
friendlyId: source.friendlyId,
|
||||
payload: source.payload,
|
||||
payloadType: source.payloadType,
|
||||
createdAt: source.createdAt,
|
||||
});
|
||||
await prisma.taskRun.update({
|
||||
where: { friendlyId: source.friendlyId },
|
||||
data: { id: source.id },
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
function envFor(ctx: SeedContext) {
|
||||
return {
|
||||
id: ctx.environmentId,
|
||||
type: "DEVELOPMENT" as const,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
/** A legacy-replica handle whose taskRun.findMany throws — proves it is never hydrated from. */
|
||||
function throwingLegacyReplica(prisma: PrismaClient): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findMany") {
|
||||
return async () => {
|
||||
throw new Error("legacy replica hydrate must not be invoked");
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new)", () => {
|
||||
// payloadType parity: split union of NEW + legacy-replica, JSON-only, createdAt desc.
|
||||
replicationContainerTest(
|
||||
"split mode hydrates the 10-most-recent CH id-set as the union of NEW + legacy-replica rows, payloadType filtered, createdAt desc",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
// PG14 = legacy read replica AND the replication source feeding the CH id-set.
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "split1", "STANDARD");
|
||||
await mirrorParents(prismaNew, ctx, "split1");
|
||||
|
||||
const base = Date.now();
|
||||
const at = (offsetMs: number) => new Date(base - offsetMs);
|
||||
|
||||
// All rows seeded on PG14 (legacy + replication source -> CH gets the full id-set).
|
||||
const legacyOld = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_legacy_old",
|
||||
payload: JSON.stringify({ kind: "legacy-old" }),
|
||||
createdAt: at(4000),
|
||||
});
|
||||
const nonJson = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_nonjson",
|
||||
payload: "binary-bytes",
|
||||
payloadType: "application/octet-stream",
|
||||
createdAt: at(3000),
|
||||
});
|
||||
const migratedSuper = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_migrated_super",
|
||||
payload: JSON.stringify({ kind: "migrated-super" }),
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: at(2000),
|
||||
});
|
||||
const migratedJson = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_migrated_json",
|
||||
payload: JSON.stringify({ kind: "migrated-json" }),
|
||||
createdAt: at(1000),
|
||||
});
|
||||
|
||||
// The two "migrated" runs ALSO live on NEW (PG17), authoritative during retention.
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migratedSuper.id,
|
||||
friendlyId: migratedSuper.friendlyId,
|
||||
payload: migratedSuper.payload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: migratedSuper.createdAt,
|
||||
});
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migratedJson.id,
|
||||
friendlyId: migratedJson.friendlyId,
|
||||
payload: migratedJson.payload,
|
||||
payloadType: JSON_TYPE,
|
||||
createdAt: migratedJson.createdAt,
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
expect(result.foundTask).toBe(true);
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
|
||||
// Union of the JSON/super+json rows across both DBs, createdAt desc, non-JSON absent.
|
||||
expect(result.runs.map((r) => r.friendlyId)).toEqual([
|
||||
"run_migrated_json",
|
||||
"run_migrated_super",
|
||||
"run_legacy_old",
|
||||
]);
|
||||
expect(result.runs.map((r) => r.friendlyId)).not.toContain("run_nonjson");
|
||||
expect(result.runs.find((r) => r.id === nonJson.id)).toBeUndefined();
|
||||
|
||||
// payloadType round-trips byte-identically across PG14/PG17.
|
||||
expect(result.runs.find((r) => r.id === migratedSuper.id)!.payloadType).toBe(
|
||||
SUPERJSON_TYPE
|
||||
);
|
||||
expect(result.runs.find((r) => r.id === migratedJson.id)!.payloadType).toBe(JSON_TYPE);
|
||||
expect(result.runs.find((r) => r.id === legacyOld.id)!.payloadType).toBe(JSON_TYPE);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Old in-retention run served from the legacy READ REPLICA only (no legacyWriter field exists).
|
||||
replicationContainerTest(
|
||||
"an in-retention legacy-only run hydrates from the legacy replica handle",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "legacyonly", "STANDARD");
|
||||
await mirrorParents(prismaNew, ctx, "legacyonly");
|
||||
|
||||
const legacyOnly = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_legacy_only",
|
||||
payload: JSON.stringify({ kind: "legacy-only" }),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// The deps shape exposes only `legacyReplica` — there is no `legacyWriter`/primary
|
||||
// field, so the legacy primary is structurally unreachable from this hydrate.
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
expect(result.runs.map((r) => r.id)).toEqual([legacyOnly.id]);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Passthrough (single-DB): one plain store read, the legacy replica never touched.
|
||||
replicationContainerTest(
|
||||
"single-DB passthrough hydrates from one store read and never touches the legacy replica",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough", "STANDARD");
|
||||
const base = Date.now();
|
||||
const newer = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_newer",
|
||||
payload: JSON.stringify({ n: 2 }),
|
||||
createdAt: new Date(base - 1000),
|
||||
});
|
||||
const older = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_older",
|
||||
payload: JSON.stringify({ n: 1 }),
|
||||
createdAt: new Date(base - 2000),
|
||||
});
|
||||
await createRun(prisma, ctx, {
|
||||
friendlyId: "run_nonjson",
|
||||
payload: "bytes",
|
||||
payloadType: "application/octet-stream",
|
||||
createdAt: new Date(base - 500),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// No readThrough (split off). Inject a throwing legacy replica to prove the split branch
|
||||
// is never entered: a runStore whose findRuns drives the single `prisma` handle.
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: false,
|
||||
legacyReplica: throwingLegacyReplica(prisma),
|
||||
},
|
||||
new PostgresRunStore({ prisma, readOnlyPrisma: prisma })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
// createdAt desc, JSON-only.
|
||||
expect(result.runs.map((r) => r.id)).toEqual([newer.id, older.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// SCHEDULED-source parity: same hydrate path, ScheduledRun mapping exercised.
|
||||
replicationContainerTest(
|
||||
"SCHEDULED task: split union parses to ScheduledRun shape identically to single-DB",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "scheduled", "SCHEDULED");
|
||||
await mirrorParents(prismaNew, ctx, "scheduled");
|
||||
|
||||
// super+json so parsePacket revives the Date fields the ScheduledTaskPayload schema requires.
|
||||
const schedulePayload = superjson.stringify({
|
||||
scheduleId: "sched_1",
|
||||
type: "IMPERATIVE",
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
timezone: "UTC",
|
||||
externalId: "ext-1",
|
||||
upcoming: [new Date("2026-01-02T00:00:00.000Z")],
|
||||
});
|
||||
|
||||
const base = Date.now();
|
||||
const migrated = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_sched_new",
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: new Date(base - 1000),
|
||||
});
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migrated.id,
|
||||
friendlyId: migrated.friendlyId,
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: migrated.createdAt,
|
||||
});
|
||||
const legacy = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_sched_legacy",
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: new Date(base - 2000),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "SCHEDULED") {
|
||||
throw new Error("expected a SCHEDULED task");
|
||||
}
|
||||
expect(result.runs.map((r) => r.id)).toEqual([migrated.id, legacy.id]);
|
||||
// Parsed ScheduledRun payload shape.
|
||||
expect(result.runs[0].payload.timezone).toBe("UTC");
|
||||
expect(result.runs[0].payload.externalId).toBe("ext-1");
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The runsRepository module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
||||
// at load. Stub it (the existing runsRepository.part*.test.ts / readthrough test do the same) — the
|
||||
// resolver under test is driven entirely through injected real containers, never the stubbed
|
||||
// module singletons.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { ClickHouseRunListResolver } from "~/services/realtime/clickHouseRunListResolver.server";
|
||||
import { setupClickhouseReplication } from "../utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require these to
|
||||
* exist, and this container doubles as the logical-replication source that feeds the
|
||||
* ClickHouse id-set, so all runs whose ids we expect from ClickHouse are seeded here.
|
||||
*/
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** A second environment in the same project — used to prove the CH filter excludes other envs. */
|
||||
async function seedSecondEnvironment(prisma: PrismaClient, ctx: SeedContext, slug: string) {
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}-2`,
|
||||
type: "PRODUCTION",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}-2`,
|
||||
},
|
||||
});
|
||||
return runtimeEnvironment.id;
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext & { environmentId?: string },
|
||||
run: { friendlyId: string; runTags?: string[]; createdAt?: Date }
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: "PENDING",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "test",
|
||||
runTags: run.runTags ?? [],
|
||||
...(run.createdAt ? { createdAt: run.createdAt } : {}),
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a real prisma client so ONLY `taskRun.findMany` throws — every other member stays a real
|
||||
* handle. The resolver's id-set path (`listRunIds` -> `listRunRows`) performs no `taskRun.findMany`
|
||||
* and never calls the run-ops store, so this proxy must never trip. The CPRES-owned filter
|
||||
* resolution that DOES run for a `batchId` filter uses `batchTaskRun.findFirst`, which this proxy
|
||||
* leaves intact.
|
||||
*/
|
||||
function throwingTaskRunFindMany(prisma: PrismaClient): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findMany") {
|
||||
return async () => {
|
||||
throw new Error(
|
||||
"taskRun.findMany must not be invoked on the realtime id-set path (a hydrate leaked in)"
|
||||
);
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", () => {
|
||||
// resolves the CH id-set with NO TaskRun PG hydrate.
|
||||
replicationContainerTest(
|
||||
"resolves the ClickHouse id-set for run-ops rows without ever reading TaskRun in Postgres",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "idset");
|
||||
|
||||
const runA = await createRun(prisma, ctx, { friendlyId: "run_idsetA" });
|
||||
const runB = await createRun(prisma, ctx, { friendlyId: "run_idsetB" });
|
||||
const runC = await createRun(prisma, ctx, { friendlyId: "run_idsetC" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// ONLY taskRun.findMany throws; the rest of the client is real so the resolver can run.
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Asserting as a set: equal createdAt makes the CH (created_at, run_id) DESC tie-break the
|
||||
// only ordering signal. The throwing proxy never tripped -> no TaskRun hydrate on this path.
|
||||
expect([...runIds].sort()).toEqual([runA.id, runB.id, runC.id].sort());
|
||||
}
|
||||
);
|
||||
|
||||
// CH filter is split-neutral (ids independent of PG residency).
|
||||
replicationContainerTest(
|
||||
"returns the same id-set regardless of which Postgres the rows are hydrated from (CH-only path)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
// A second, unrelated NEW client carrying NO rows. The id-set path never touches it;
|
||||
// pointing the resolver at it must not change the result, proving the ids come from CH only.
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "neutral");
|
||||
const runA = await createRun(prisma, ctx, { friendlyId: "run_neutralA" });
|
||||
const runB = await createRun(prisma, ctx, { friendlyId: "run_neutralB" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const filter = {
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
// "single-DB" wiring: resolver's prisma is the replication-source DB (where rows live).
|
||||
const singleDb = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
// "split" wiring: resolver's prisma is the empty NEW DB. If the id-set path read TaskRun
|
||||
// from this handle the result would differ; it must not.
|
||||
const split = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: prismaNew,
|
||||
});
|
||||
|
||||
const idsSingleDb = await singleDb.resolveMatchingRunIds(filter);
|
||||
const idsSplit = await split.resolveMatchingRunIds(filter);
|
||||
|
||||
expect(idsSplit).toEqual(idsSingleDb);
|
||||
expect([...idsSingleDb].sort()).toEqual([runA.id, runB.id].sort());
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// single-DB passthrough; no legacy/known-migrated probe on this path.
|
||||
replicationContainerTest(
|
||||
"single-DB passthrough returns the CH id-set and never hydrates TaskRun",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough");
|
||||
const run = await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toEqual([run.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// a far-future straggler's id surfaces from the CH id-set.
|
||||
replicationContainerTest(
|
||||
"surfaces a far-future delayed run's id from the CH id-set",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "straggler");
|
||||
|
||||
const now = new Date();
|
||||
const near = await createRun(prisma, ctx, { friendlyId: "run_near", createdAt: now });
|
||||
// The migrated-by-sweep case: CH is residency-agnostic, so the id surfaces once indexed
|
||||
// regardless of which DB holds the row.
|
||||
const farFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const straggler = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_straggler",
|
||||
createdAt: farFuture,
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toContain(straggler.id);
|
||||
expect(runIds).toContain(near.id);
|
||||
// (created_at, run_id) DESC ordering -> the far-future straggler sorts ahead of the near run.
|
||||
expect(runIds.indexOf(straggler.id)).toBeLessThan(runIds.indexOf(near.id));
|
||||
}
|
||||
);
|
||||
|
||||
// tag match is contains-ALL (tagsMatch: "all" -> hasAll), authoritative.
|
||||
// The sibling runReader.server.ts JSDoc still calls RunListFilter.tags "Contains-ANY"; that is
|
||||
// stale. The resolver passes tagsMatch: "all" and the live CH repo maps
|
||||
// it to hasAll, so contains-ALL is the real behavior — assert that, not the JSDoc.
|
||||
replicationContainerTest(
|
||||
"tag filter is contains-ALL: only runs carrying every requested tag are returned",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "tags");
|
||||
|
||||
// Has BOTH requested tags -> matches contains-ALL.
|
||||
const both = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_bothTags",
|
||||
runTags: ["alpha", "beta"],
|
||||
});
|
||||
// Has only one of the requested tags -> excluded under contains-ALL (would match contains-ANY).
|
||||
await createRun(prisma, ctx, { friendlyId: "run_oneTag", runTags: ["alpha"] });
|
||||
// Has neither -> excluded.
|
||||
await createRun(prisma, ctx, { friendlyId: "run_otherTag", runTags: ["gamma"] });
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
tags: ["alpha", "beta"],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// contains-ALL: only the run with BOTH tags. (contains-ANY would also return run_oneTag.)
|
||||
expect(runIds).toEqual([both.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// environment scoping: the CH filter excludes other environments.
|
||||
// Doubles as a structural proof that an accidental hydrate would NOT change the id-set: rows on a
|
||||
// different env are not returned because CH filters by environment_id, not because PG was read.
|
||||
replicationContainerTest(
|
||||
"scopes the id-set to the filtered environment (other-env rows are excluded by the CH filter)",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "envscope");
|
||||
const otherEnvId = await seedSecondEnvironment(prisma, ctx, "envscope");
|
||||
|
||||
const inEnv = await createRun(prisma, ctx, { friendlyId: "run_inEnv" });
|
||||
await createRun(
|
||||
prisma,
|
||||
{ ...ctx, environmentId: otherEnvId },
|
||||
{ friendlyId: "run_otherEnv" }
|
||||
);
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const resolver = new ClickHouseRunListResolver({
|
||||
getClickhouse: async () => clickhouse,
|
||||
prisma: throwingTaskRunFindMany(prisma),
|
||||
});
|
||||
|
||||
const runIds = await resolver.resolveMatchingRunIds({
|
||||
organizationId: ctx.organizationId,
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(runIds).toEqual([inEnv.id]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
// Real-PG proof for the RunPresenter run-detail read seam. The DB is never mocked:
|
||||
// the only vi.mock redirects the `~/db.server` module handle (captured by the
|
||||
// `runStore` singleton at load) to the live testcontainer client via a delegating
|
||||
// Proxy. Seeding `logsDeletedAt` + `showDeletedLogs: false` makes `showLogs` false,
|
||||
// so the presenter returns the header EARLY — before the trace path and the
|
||||
// `user.findFirst` admin read — keeping the test off ClickHouse.
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// Hoisted alongside the vi.mock factory. `setCurrentPrisma` points the delegating
|
||||
// Proxy at each test's container. The RunStore singleton is built ONCE at import and
|
||||
// its error-normalizing wrapper memoizes each Prisma model delegate on first access;
|
||||
// returning `current.taskRun` directly would freeze the store onto the first test's
|
||||
// container ("Database test_0 does not exist" on later tests). So object-valued
|
||||
// delegates return a STABLE per-key sub-proxy the store can safely cache, which
|
||||
// re-delegates to the live `current[key]` on every access; functions/scalars pass
|
||||
// through to the live client.
|
||||
const { delegating, setCurrentPrisma } = vi.hoisted(() => {
|
||||
let current: any = undefined;
|
||||
const subProxyCache = new Map<string, unknown>();
|
||||
|
||||
const getSubProxy = (prop: string) => {
|
||||
const cached = subProxyCache.get(prop);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const subProxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_st, key) {
|
||||
if (!current) {
|
||||
throw new Error("currentPrisma not set");
|
||||
}
|
||||
const delegate = current[prop];
|
||||
const value = delegate?.[key];
|
||||
return typeof value === "function" ? value.bind(delegate) : value;
|
||||
},
|
||||
}
|
||||
);
|
||||
subProxyCache.set(prop, subProxy);
|
||||
return subProxy;
|
||||
};
|
||||
|
||||
const proxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (!current) {
|
||||
throw new Error("currentPrisma not set");
|
||||
}
|
||||
if (typeof prop === "string") {
|
||||
const value = current[prop];
|
||||
if (value != null && typeof value === "object") {
|
||||
return getSubProxy(prop);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return current[prop];
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
delegating: proxy,
|
||||
setCurrentPrisma: (p: unknown) => {
|
||||
current = p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: delegating,
|
||||
$replica: delegating,
|
||||
}));
|
||||
|
||||
// Imported AFTER the hoisted vi.mock so the singleton (built from `~/db.server`)
|
||||
// captures the Proxy as its `readOnlyPrisma`.
|
||||
import {
|
||||
RunEnvironmentMismatchError,
|
||||
RunNotInPgError,
|
||||
RunPresenter,
|
||||
} from "~/presenters/v3/RunPresenter.server";
|
||||
|
||||
let suffixCounter = 0;
|
||||
function uniqueSuffix(prefix: string) {
|
||||
suffixCounter += 1;
|
||||
return `${prefix}-${suffixCounter}-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function seedOrgProjectEnvMember(prisma: PrismaClient, suffix: string) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `${suffix}@test.com`,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
},
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
title: `org-${suffix}`,
|
||||
slug: `org-${suffix}`,
|
||||
members: { create: { userId: user.id, role: "ADMIN" } },
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${suffix}`,
|
||||
slug: `proj-${suffix}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `ext-${suffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${suffix}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
orgMemberId: organization.members[0]!.id,
|
||||
apiKey: `tr_dev_${suffix}`,
|
||||
pkApiKey: `pk_dev_${suffix}`,
|
||||
shortcode: `sc-${suffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, organization, project, runtimeEnvironment };
|
||||
}
|
||||
|
||||
async function seedRun(
|
||||
prisma: PrismaClient,
|
||||
ids: { id: string; friendlyId: string },
|
||||
env: { runtimeEnvironmentId: string; projectId: string; organizationId: string }
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
id: ids.id,
|
||||
friendlyId: ids.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
payload: JSON.stringify({ foo: "bar" }),
|
||||
payloadType: "application/json",
|
||||
traceId: "trace-1234",
|
||||
spanId: "span-1234",
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: env.runtimeEnvironmentId,
|
||||
projectId: env.projectId,
|
||||
organizationId: env.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
// logsDeletedAt set → showLogs is false → presenter returns the header
|
||||
// EARLY, before the trace path and before the `user.findFirst` admin read.
|
||||
logsDeletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunPresenter run read seam (single-DB, real PG)", () => {
|
||||
postgresTest(
|
||||
"passthrough resolves the run-detail header via the singleton",
|
||||
async ({ prisma }) => {
|
||||
setCurrentPrisma(prisma);
|
||||
|
||||
const suffix = uniqueSuffix("pt");
|
||||
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
||||
prisma,
|
||||
suffix
|
||||
);
|
||||
|
||||
const id = generateKsuidId();
|
||||
const friendlyId = `run_${id}`;
|
||||
const run = await seedRun(
|
||||
prisma,
|
||||
{ id, friendlyId },
|
||||
{
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
}
|
||||
);
|
||||
|
||||
const presenter = new RunPresenter(prisma);
|
||||
const result = await presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: runtimeEnvironment.slug,
|
||||
runFriendlyId: friendlyId,
|
||||
showDeletedLogs: false,
|
||||
showDebug: false,
|
||||
});
|
||||
|
||||
// Header served through the store seam (singleton → Proxy → real container).
|
||||
expect(result.run.id).toBe(id);
|
||||
expect(result.run.friendlyId).toBe(friendlyId);
|
||||
expect(result.run.number).toBe(run.number);
|
||||
expect(result.run.status).toBe("COMPLETED_SUCCESSFULLY");
|
||||
expect(result.run.environment.slug).toBe(runtimeEnvironment.slug);
|
||||
// logsDeletedAt is set → early return, no trace.
|
||||
expect(result.trace).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest(
|
||||
"run read is NOT pinned to the constructor client (served via the seam)",
|
||||
async ({ prisma }) => {
|
||||
setCurrentPrisma(prisma);
|
||||
|
||||
const suffix = uniqueSuffix("unpinned");
|
||||
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
||||
prisma,
|
||||
suffix
|
||||
);
|
||||
|
||||
const id = generateKsuidId();
|
||||
const friendlyId = `run_${id}`;
|
||||
await seedRun(
|
||||
prisma,
|
||||
{ id, friendlyId },
|
||||
{
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
}
|
||||
);
|
||||
|
||||
// Sentinel constructor client whose taskRun.findFirst THROWS: if the run read
|
||||
// were pinned to this.#prismaClient the call would reject — it resolves because
|
||||
// the run read flows through the store seam/singleton instead. The control-plane
|
||||
// lookups (project-scope + membership auth; the trace-path admin user.findFirst)
|
||||
// are NOT split and correctly use the constructor client, so they are stubbed to
|
||||
// succeed while taskRun stays throwing.
|
||||
const sentinel = {
|
||||
taskRun: {
|
||||
findFirst: () => {
|
||||
throw new Error("run read must not use constructor client");
|
||||
},
|
||||
},
|
||||
project: {
|
||||
findFirst: async () => ({ id: project.id }),
|
||||
},
|
||||
user: {
|
||||
findFirst: async () => user,
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const presenter = new RunPresenter(sentinel);
|
||||
const result = await presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: runtimeEnvironment.slug,
|
||||
runFriendlyId: friendlyId,
|
||||
showDeletedLogs: false,
|
||||
showDebug: false,
|
||||
});
|
||||
|
||||
expect(result.run.id).toBe(id);
|
||||
expect(result.run.friendlyId).toBe(friendlyId);
|
||||
expect(result.trace).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
postgresTest("missing run maps to RunNotInPgError", async ({ prisma }) => {
|
||||
setCurrentPrisma(prisma);
|
||||
|
||||
const suffix = uniqueSuffix("notfound");
|
||||
const { user, project, runtimeEnvironment } = await seedOrgProjectEnvMember(prisma, suffix);
|
||||
|
||||
const missingFriendlyId = `run_${generateKsuidId()}`;
|
||||
|
||||
const presenter = new RunPresenter(prisma);
|
||||
await expect(
|
||||
presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: project.slug,
|
||||
environmentSlug: runtimeEnvironment.slug,
|
||||
runFriendlyId: missingFriendlyId,
|
||||
showDeletedLogs: false,
|
||||
showDebug: false,
|
||||
})
|
||||
).rejects.toThrow(RunNotInPgError);
|
||||
});
|
||||
|
||||
postgresTest("environment mismatch maps to RunEnvironmentMismatchError", async ({ prisma }) => {
|
||||
setCurrentPrisma(prisma);
|
||||
|
||||
const suffix = uniqueSuffix("mismatch");
|
||||
const { user, organization, project, runtimeEnvironment } = await seedOrgProjectEnvMember(
|
||||
prisma,
|
||||
suffix
|
||||
);
|
||||
|
||||
const id = generateKsuidId();
|
||||
const friendlyId = `run_${id}`;
|
||||
await seedRun(
|
||||
prisma,
|
||||
{ id, friendlyId },
|
||||
{
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
}
|
||||
);
|
||||
|
||||
const presenter = new RunPresenter(prisma);
|
||||
await expect(
|
||||
presenter.call({
|
||||
userId: user.id,
|
||||
projectSlug: project.slug,
|
||||
// Seeded env slug is `env-${suffix}`; pass a different slug.
|
||||
environmentSlug: `other-${suffix}`,
|
||||
runFriendlyId: friendlyId,
|
||||
showDeletedLogs: false,
|
||||
showDebug: false,
|
||||
})
|
||||
).rejects.toThrow(RunEnvironmentMismatchError);
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,22 @@ vi.mock("~/services/taskMetadataCacheInstance.server", async () => {
|
||||
return { taskMetadataCacheInstance: new NoopTaskMetadataCache() };
|
||||
});
|
||||
|
||||
// The org-data-stores registry singleton is constructed at import (transitively via
|
||||
// the ClickHouse factory instance, which many presenters pull in). Its ctor fires a
|
||||
// `forever` pRetry(loadFromDatabase) plus a setInterval reload against db.server's
|
||||
// $replica; in CI (no Postgres) those retry forever, blocking the worker until any
|
||||
// awaiting test's hook times out. Stub the instance to a no-op — no unit test uses
|
||||
// this singleton (the registry-behavior tests construct the class directly).
|
||||
vi.mock("~/services/dataStores/organizationDataStoresRegistryInstance.server", () => ({
|
||||
organizationDataStoresRegistry: {
|
||||
isReady: Promise.resolve(),
|
||||
isLoaded: true,
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/v3/runEngine.server", () => ({ engine: noopProxy() }));
|
||||
vi.mock("~/v3/marqs/index.server", () => ({ marqs: noopProxy(), MarQS: class {} }));
|
||||
vi.mock("~/v3/marqs/devPubSub.server", () => ({ devPubSub: noopProxy() }));
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
|
||||
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const TASK_RUN_CROSS_SEAM_FKS = [
|
||||
"TaskRun_runtimeEnvironmentId_fkey",
|
||||
"TaskRun_projectId_fkey",
|
||||
"TaskRun_organizationId_fkey",
|
||||
// lockedBy/lockedToVersion point at control-plane rows (BackgroundWorkerTask /
|
||||
// BackgroundWorker) that live only on PG14 — drop their FKs on the PG17 run-ops DB.
|
||||
"TaskRun_lockedById_fkey",
|
||||
"TaskRun_lockedToVersionId_fkey",
|
||||
] as const;
|
||||
|
||||
async function dropTaskRunCrossSeamFks(prisma: PrismaClient) {
|
||||
for (const constraint of TASK_RUN_CROSS_SEAM_FKS) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "TaskRun" DROP CONSTRAINT IF EXISTS "${constraint}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let n = 0;
|
||||
async function seedControlPlaneWithWorker(prisma: PrismaClient) {
|
||||
const s = n++;
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${s}`, slug: `org-${s}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `P ${s}`,
|
||||
slug: `p-${s}`,
|
||||
externalRef: `proj_${s}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "PRODUCTION",
|
||||
slug: `env-${s}`,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_${s}`,
|
||||
pkApiKey: `pk_${s}`,
|
||||
shortcode: `sc_${s}`,
|
||||
},
|
||||
});
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${s}`,
|
||||
contentHash: `hash_${s}`,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: `2024.1.${s}`,
|
||||
metadata: {},
|
||||
engine: "V2",
|
||||
sdkVersion: "3.0.0",
|
||||
},
|
||||
});
|
||||
const task = await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${s}`,
|
||||
slug: `t-${s}`,
|
||||
filePath: "src/index.ts",
|
||||
exportName: "myTask",
|
||||
workerId: worker.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment, worker, task };
|
||||
}
|
||||
|
||||
describe("SpanPresenter cross-DB read-through", () => {
|
||||
heteroPostgresTest(
|
||||
"env + lockedToVersion + lockedBy resolve from PG14 while run scalars resolve from PG17",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
await dropTaskRunCrossSeamFks(prisma17 as unknown as PrismaClient);
|
||||
const cp = await seedControlPlaneWithWorker(prisma14 as unknown as PrismaClient);
|
||||
|
||||
const _run = await (prisma17 as unknown as PrismaClient).taskRun.create({
|
||||
data: {
|
||||
id: `run_${n++}_pg17`,
|
||||
engine: "V2",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
friendlyId: `run_sp_${n}`,
|
||||
runtimeEnvironmentId: cp.environment.id,
|
||||
organizationId: cp.organization.id,
|
||||
projectId: cp.project.id,
|
||||
lockedById: cp.task.id,
|
||||
lockedToVersionId: cp.worker.id,
|
||||
taskIdentifier: "sp-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
queue: "task/sp-task",
|
||||
traceId: "trace_sp",
|
||||
spanId: "span_sp",
|
||||
workerQueue: "main",
|
||||
},
|
||||
});
|
||||
|
||||
const runStore = new PostgresRunStore({
|
||||
prisma: prisma17 as unknown as PrismaClient,
|
||||
readOnlyPrisma: prisma17 as unknown as PrismaClient,
|
||||
});
|
||||
const resolver = new ControlPlaneResolver({
|
||||
controlPlanePrimary: prisma14 as unknown as PrismaClient,
|
||||
controlPlaneReplica: prisma14 as unknown as PrismaClient,
|
||||
cache: new ControlPlaneCache(),
|
||||
splitEnabled: () => false,
|
||||
});
|
||||
|
||||
const found = await runStore.findRun(
|
||||
{ spanId: "span_sp", runtimeEnvironmentId: cp.environment.id },
|
||||
{
|
||||
select: {
|
||||
id: true,
|
||||
runtimeEnvironmentId: true,
|
||||
lockedById: true,
|
||||
lockedToVersionId: true,
|
||||
},
|
||||
},
|
||||
prisma17 as unknown as PrismaClient
|
||||
);
|
||||
expect(found).not.toBeNull();
|
||||
|
||||
const env = await resolver.resolveAuthenticatedEnv(found!.runtimeEnvironmentId);
|
||||
expect(env!.id).toBe(cp.environment.id);
|
||||
// AuthenticatedEnvironment carries org at the TOP LEVEL (env.organization), not under
|
||||
// project — the decomposed SpanPresenter reads env.organization.{id,slug,title}.
|
||||
expect(env!.organization.title).toBe(cp.organization.title);
|
||||
expect(env!.project.externalRef).toBe(cp.project.externalRef);
|
||||
|
||||
const locked = await resolver.resolveRunLockedWorker({
|
||||
lockedById: found!.lockedById,
|
||||
lockedToVersionId: found!.lockedToVersionId,
|
||||
});
|
||||
expect(locked!.lockedBy!.filePath).toBe("src/index.ts");
|
||||
expect(locked!.lockedToVersion!.version).toBe(cp.worker.version);
|
||||
expect(locked!.lockedToVersion!.sdkVersion).toBe("3.0.0");
|
||||
|
||||
expect(await (prisma17 as unknown as PrismaClient).runtimeEnvironment.count()).toBe(0);
|
||||
expect(await (prisma14 as unknown as PrismaClient).taskRun.count()).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// `var` (not `let`) so it is hoisted + initialized to `undefined` before the mocked
|
||||
// module's getters are first read at import time (featureFlags.server reads `prisma`
|
||||
// during module eval).
|
||||
var dbClientHolder: any = undefined;
|
||||
function setDbClient(client: any) {
|
||||
dbClientHolder = client;
|
||||
}
|
||||
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
return {
|
||||
Prisma,
|
||||
sqlDatabaseSchema: Prisma.sql(["public"]),
|
||||
get prisma() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
get $replica() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
heteroPostgresTest,
|
||||
heteroRunOpsPostgresTest,
|
||||
postgresTest,
|
||||
} from "@internal/testcontainers";
|
||||
import { Prisma, type PrismaClient, type WaitpointStatus } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
WaitpointListPresenter,
|
||||
type WaitpointListOptions,
|
||||
} from "~/presenters/v3/WaitpointListPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
// V2 so determineEngineVersion does not short-circuit to the V1 mismatch gate.
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
// PRODUCTION so determineEngineVersion takes the deployment branch (an empty
|
||||
// promotion read on the real container) and falls through to project.engine = V2.
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { projectId: project.id, environmentId: runtimeEnvironment.id };
|
||||
}
|
||||
|
||||
type SeedWaitpoint = {
|
||||
id: string;
|
||||
type?: "MANUAL" | "RUN";
|
||||
status?: WaitpointStatus;
|
||||
outputIsError?: boolean;
|
||||
idempotencyKey?: string;
|
||||
inactiveIdempotencyKey?: string | null;
|
||||
userProvidedIdempotencyKey?: boolean;
|
||||
tags?: string[];
|
||||
createdAt?: Date;
|
||||
};
|
||||
|
||||
async function seedWaitpoint(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
wp: SeedWaitpoint
|
||||
): Promise<void> {
|
||||
await prisma.waitpoint.create({
|
||||
data: {
|
||||
id: wp.id,
|
||||
friendlyId: `wp_${wp.id}`,
|
||||
type: wp.type ?? "MANUAL",
|
||||
status: wp.status ?? "PENDING",
|
||||
outputIsError: wp.outputIsError ?? false,
|
||||
idempotencyKey: wp.idempotencyKey ?? `idem-${wp.id}`,
|
||||
userProvidedIdempotencyKey: wp.userProvidedIdempotencyKey ?? false,
|
||||
inactiveIdempotencyKey: wp.inactiveIdempotencyKey ?? null,
|
||||
tags: wp.tags ?? [],
|
||||
createdAt: wp.createdAt ?? new Date(),
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function baseOptions(
|
||||
environmentId: string,
|
||||
overrides: Partial<WaitpointListOptions> = {}
|
||||
): WaitpointListOptions {
|
||||
return {
|
||||
environment: {
|
||||
id: environmentId,
|
||||
type: "PRODUCTION",
|
||||
project: { id: "irrelevant", engine: "V2" },
|
||||
apiKey: "tr_prod_test",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// The exact presenter scan SQL, run directly for the byte-identity / ORDER-BY proof.
|
||||
function rawScan(
|
||||
prisma: PrismaClient,
|
||||
environmentId: string,
|
||||
direction: "forward" | "backward",
|
||||
limit: number
|
||||
) {
|
||||
const schema = Prisma.sql(["public"]);
|
||||
return prisma.$queryRaw`
|
||||
SELECT
|
||||
w.id,
|
||||
w."friendlyId",
|
||||
w.status,
|
||||
w."completedAt",
|
||||
w."completedAfter",
|
||||
w."outputIsError",
|
||||
w."idempotencyKey",
|
||||
w."idempotencyKeyExpiresAt",
|
||||
w."inactiveIdempotencyKey",
|
||||
w."userProvidedIdempotencyKey",
|
||||
w."tags",
|
||||
w."createdAt"
|
||||
FROM
|
||||
${schema}."Waitpoint" w
|
||||
WHERE
|
||||
w."environmentId" = ${environmentId}
|
||||
AND w.type = 'MANUAL'
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`w.id DESC` : Prisma.sql`w.id ASC`}
|
||||
LIMIT ${limit}`;
|
||||
}
|
||||
|
||||
describe("WaitpointListPresenter read-route", () => {
|
||||
// Single-DB short-circuits to one handle (passthrough).
|
||||
postgresTest("passthrough: single handle, legacy closures never touched", async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
const ctx = await seedParents(prisma, "passthrough");
|
||||
|
||||
await seedWaitpoint(prisma, ctx, { id: "wp00000000000000000000001", status: "PENDING" });
|
||||
await seedWaitpoint(prisma, ctx, {
|
||||
id: "wp00000000000000000000002",
|
||||
status: "COMPLETED",
|
||||
outputIsError: false,
|
||||
tags: ["b", "a"],
|
||||
});
|
||||
await seedWaitpoint(prisma, ctx, {
|
||||
id: "wp00000000000000000000003",
|
||||
status: "COMPLETED",
|
||||
outputIsError: true,
|
||||
});
|
||||
// Non-MANUAL row that must be excluded by w.type = 'MANUAL'.
|
||||
await seedWaitpoint(prisma, ctx, { id: "wp00000000000000000000099", type: "RUN" });
|
||||
|
||||
// Spy: any would-be legacy handle access throws if invoked.
|
||||
const legacyThrows = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("legacy handle must never be touched in passthrough");
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaClient;
|
||||
|
||||
const presenter = new WaitpointListPresenter(prisma, prisma);
|
||||
const result = await presenter.call(baseOptions(ctx.environmentId, { pageSize: 2 }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
// Page of 2, id DESC (forward).
|
||||
expect(result.tokens.map((t) => t.id)).toEqual([
|
||||
"wp_wp00000000000000000000003",
|
||||
"wp_wp00000000000000000000002",
|
||||
]);
|
||||
expect(result.pagination.next).toBe("wp00000000000000000000002");
|
||||
expect(result.pagination.previous).toBeUndefined();
|
||||
expect(result.hasAnyTokens).toBe(true);
|
||||
|
||||
// Matches a direct $queryRaw over the same SQL (excludes RUN type).
|
||||
const direct = (await rawScan(prisma, ctx.environmentId, "forward", 3)) as { id: string }[];
|
||||
expect(direct.map((r) => r.id)).toEqual([
|
||||
"wp00000000000000000000003",
|
||||
"wp00000000000000000000002",
|
||||
"wp00000000000000000000001",
|
||||
]);
|
||||
|
||||
// Constructing with a throwing legacy handle but no split must never invoke it.
|
||||
const presenterWithLegacy = new WaitpointListPresenter(prisma, prisma, {
|
||||
runOpsLegacyReplica: legacyThrows,
|
||||
// splitEnabled omitted => passthrough.
|
||||
});
|
||||
const result2 = await presenterWithLegacy.call(baseOptions(ctx.environmentId, { pageSize: 2 }));
|
||||
expect(result2.success).toBe(true);
|
||||
});
|
||||
|
||||
// Raw paginated scan byte-identical + identical ORDER-BY across PG14/PG17.
|
||||
heteroPostgresTest(
|
||||
"keyset scan byte-identical + identical ORDER-BY on PG14 and PG17",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const corpus: SeedWaitpoint[] = [
|
||||
{
|
||||
id: "wp10000000000000000000001",
|
||||
status: "PENDING",
|
||||
tags: ["alpha", "beta"],
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "wp10000000000000000000002",
|
||||
status: "COMPLETED",
|
||||
outputIsError: false,
|
||||
idempotencyKey: "key-2",
|
||||
userProvidedIdempotencyKey: true,
|
||||
inactiveIdempotencyKey: "old-2",
|
||||
createdAt: new Date("2024-02-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "wp10000000000000000000003",
|
||||
status: "COMPLETED",
|
||||
outputIsError: true,
|
||||
tags: ["gamma"],
|
||||
createdAt: new Date("2024-03-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "wp10000000000000000000004",
|
||||
status: "PENDING",
|
||||
createdAt: new Date("2024-04-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "wp10000000000000000000005",
|
||||
status: "COMPLETED",
|
||||
outputIsError: false,
|
||||
createdAt: new Date("2024-05-01T00:00:00Z"),
|
||||
},
|
||||
// excluded
|
||||
{ id: "wp10000000000000000000099", type: "RUN" },
|
||||
];
|
||||
|
||||
// determineEngineVersion reads the module-level prisma (the holder); point it at
|
||||
// prisma14, which holds the env used by the presenter call below.
|
||||
setDbClient(prisma14);
|
||||
const ctx14 = await seedParents(prisma14, "hetero14");
|
||||
const ctx17 = await seedParents(prisma17, "hetero17");
|
||||
for (const wp of corpus) {
|
||||
await seedWaitpoint(prisma14, ctx14, wp);
|
||||
await seedWaitpoint(prisma17, ctx17, wp);
|
||||
}
|
||||
|
||||
for (const direction of ["forward", "backward"] as const) {
|
||||
const rows14 = (await rawScan(prisma14, ctx14.environmentId, direction, 100)) as any[];
|
||||
const rows17 = (await rawScan(prisma17, ctx17.environmentId, direction, 100)) as any[];
|
||||
|
||||
// Identical ORDER-BY sequence across versions.
|
||||
expect(rows14.map((r) => r.id)).toEqual(rows17.map((r) => r.id));
|
||||
// Byte-identical row content (id-keyed so env-id difference doesn't matter — env id is not selected).
|
||||
expect(rows14).toEqual(rows17);
|
||||
// The MANUAL filter excludes the RUN waitpoint.
|
||||
expect(rows14.some((r) => r.id === "wp10000000000000000000099")).toBe(false);
|
||||
}
|
||||
|
||||
// Same with a cursor active (forward => id < cursor) — exercised via the presenter.
|
||||
const presenter14 = new WaitpointListPresenter(prisma14, prisma14);
|
||||
const cursored = await presenter14.call(
|
||||
baseOptions(ctx14.environmentId, { pageSize: 2, cursor: "wp10000000000000000000004" })
|
||||
);
|
||||
expect(cursored.success).toBe(true);
|
||||
if (cursored.success) {
|
||||
expect(cursored.tokens.map((t) => t.id)).toEqual([
|
||||
"wp_wp10000000000000000000003",
|
||||
"wp_wp10000000000000000000002",
|
||||
]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Split scan merges migrated (new/PG17) + abandoned (legacy/PG14) tokens
|
||||
// in one keyset-ordered page; legacy READ REPLICA hit only when the new DB doesn't fill the page.
|
||||
// Structural: readRoute has no legacy-primary/writer field — only runOpsLegacyReplica.
|
||||
heteroPostgresTest(
|
||||
"split merge serves new + legacy tokens; legacy read only when new doesn't fill the page",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
// determineEngineVersion reads the module-level prisma (the holder) = the new DB.
|
||||
setDbClient(prisma17);
|
||||
// Same env id on both DBs (FK parents must exist on each side for the env-scoped WHERE).
|
||||
const ctx17 = await seedParents(prisma17, "split17");
|
||||
await seedParentsWithEnvId(prisma14, "split14", ctx17.environmentId, ctx17.projectId);
|
||||
|
||||
// New (PG17): the two most-recent (highest id) MANUAL tokens. ...004 carries the
|
||||
// authoritative post-migration status (COMPLETED) and also exists on legacy as PENDING.
|
||||
await seedWaitpoint(prisma17, ctx17, { id: "wp20000000000000000000005" });
|
||||
await seedWaitpoint(prisma17, ctx17, {
|
||||
id: "wp20000000000000000000004",
|
||||
status: "COMPLETED",
|
||||
outputIsError: false,
|
||||
});
|
||||
// Legacy (PG14): older in-retention tokens (lower ids), interleaved across the keyset order,
|
||||
// plus a stale mid-migration copy of ...004 that the de-dupe must discard.
|
||||
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000004", status: "PENDING" });
|
||||
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000003" });
|
||||
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000002" });
|
||||
await seedWaitpoint(prisma14, ctx17, { id: "wp20000000000000000000001" });
|
||||
|
||||
// Wrap the legacy client to count scans (now via waitpoint.findMany after the fix).
|
||||
let legacyScanCount = 0;
|
||||
const legacyCounted = new Proxy(prisma14, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "waitpoint") {
|
||||
const real = Reflect.get(target, prop, receiver);
|
||||
return new Proxy(real, {
|
||||
get(t, p) {
|
||||
if (p === "findMany") {
|
||||
legacyScanCount++;
|
||||
return t.findMany.bind(t);
|
||||
}
|
||||
return (t as any)[p];
|
||||
},
|
||||
});
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as PrismaClient;
|
||||
|
||||
const presenter = new WaitpointListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: legacyCounted,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
// pageSize 4 < union of 5 => new DB (2 rows) does NOT fill page+1=5, so legacy is scanned.
|
||||
const result = await presenter.call(baseOptions(ctx17.environmentId, { pageSize: 4 }));
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
// Keyset-ordered union, id DESC, page of 4 (one over-fetch dropped: the oldest id).
|
||||
expect(result.tokens.map((t) => t.id)).toEqual([
|
||||
"wp_wp20000000000000000000005",
|
||||
"wp_wp20000000000000000000004",
|
||||
"wp_wp20000000000000000000003",
|
||||
"wp_wp20000000000000000000002",
|
||||
]);
|
||||
// hasMore => next cursor is the 4th id.
|
||||
expect(result.pagination.next).toBe("wp20000000000000000000002");
|
||||
expect(legacyScanCount).toBeGreaterThan(0);
|
||||
|
||||
// De-dupe: ...004 exists on both sides; it appears exactly once and the new-DB copy
|
||||
// (COMPLETED, not the legacy PENDING => WAITING) is authoritative.
|
||||
const dupes = result.tokens.filter((t) => t.id === "wp_wp20000000000000000000004");
|
||||
expect(dupes).toHaveLength(1);
|
||||
expect(dupes[0]?.status).toBe("COMPLETED");
|
||||
|
||||
// Now a page the new DB fully satisfies => legacy must NOT be scanned.
|
||||
legacyScanCount = 0;
|
||||
const presenter2 = new WaitpointListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: legacyCounted,
|
||||
splitEnabled: true,
|
||||
});
|
||||
// pageSize 1 => page+1 = 2; new DB has 2 rows => fills the over-fetch, skip legacy.
|
||||
const result2 = await presenter2.call(baseOptions(ctx17.environmentId, { pageSize: 1 }));
|
||||
expect(result2.success).toBe(true);
|
||||
if (result2.success) {
|
||||
expect(result2.tokens.map((t) => t.id)).toEqual(["wp_wp20000000000000000000005"]);
|
||||
}
|
||||
expect(legacyScanCount).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
// Empty-state probe is dual-DB during the window (no false-empty), and reads only
|
||||
// _replica when split is off.
|
||||
heteroPostgresTest(
|
||||
"empty-state probe is dual-DB during the window",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
setDbClient(prisma17);
|
||||
const ctx = await seedParents(prisma17, "probe17");
|
||||
await seedParentsWithEnvId(prisma14, "probe14", ctx.environmentId, ctx.projectId);
|
||||
|
||||
// Zero MANUAL on NEW, exactly one on LEGACY.
|
||||
await seedWaitpoint(prisma14, ctx, { id: "wp30000000000000000000001" });
|
||||
|
||||
// Filter yields an empty page (no token has this idempotencyKey) so the probe runs.
|
||||
const splitPresenter = new WaitpointListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: prisma14,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const r1 = await splitPresenter.call(
|
||||
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
|
||||
);
|
||||
expect(r1.success).toBe(true);
|
||||
if (r1.success) {
|
||||
// Probe found the legacy row => not false-empty.
|
||||
expect(r1.tokens).toEqual([]);
|
||||
expect(r1.hasAnyTokens).toBe(true);
|
||||
}
|
||||
|
||||
// Zero on both => empty (post-termination / past-retention normal response).
|
||||
await prisma14.waitpoint.deleteMany({ where: { environmentId: ctx.environmentId } });
|
||||
const r2 = await splitPresenter.call(
|
||||
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
|
||||
);
|
||||
expect(r2.success).toBe(true);
|
||||
if (r2.success) {
|
||||
expect(r2.hasAnyTokens).toBe(false);
|
||||
}
|
||||
|
||||
// split off => probe reads only _replica, never the legacy handle (throws if touched).
|
||||
const legacyThrows = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("legacy handle must never be touched when split is off");
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaClient;
|
||||
const passthroughPresenter = new WaitpointListPresenter(prisma17, prisma17, {
|
||||
runOpsLegacyReplica: legacyThrows,
|
||||
});
|
||||
const r3 = await passthroughPresenter.call(
|
||||
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
|
||||
);
|
||||
expect(r3.success).toBe(true);
|
||||
if (r3.success) {
|
||||
// Nothing on the new DB and split off => empty.
|
||||
expect(r3.hasAnyTokens).toBe(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"scan against dedicated RunOpsPrismaClient (splitEnabled): returns waitpoints from new DB",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
setDbClient(prisma14);
|
||||
|
||||
const envId = "env_rawscan_wp_00000000001";
|
||||
const projId = "proj_rawscan_wp_0000000001";
|
||||
|
||||
await seedParentsWithEnvId(prisma14, "rawscan-wp14", envId, projId);
|
||||
|
||||
await (prisma17 as RunOpsPrismaClient).waitpoint.create({
|
||||
data: {
|
||||
id: "rwp0000000000000000000001",
|
||||
friendlyId: "wp_rwp0000000000000000000001",
|
||||
type: "MANUAL",
|
||||
status: "PENDING",
|
||||
idempotencyKey: "idem-rawscan-wp-1",
|
||||
userProvidedIdempotencyKey: false,
|
||||
outputIsError: false,
|
||||
tags: [],
|
||||
projectId: projId,
|
||||
environmentId: envId,
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new WaitpointListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call({
|
||||
environment: {
|
||||
id: envId,
|
||||
type: "PRODUCTION",
|
||||
project: { id: projId, engine: "V2" },
|
||||
apiKey: "tr_prod_rawscan",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.tokens.map((t) => t.id)).toContain("wp_rwp0000000000000000000001");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Seed org/project/env reusing a caller-supplied env+project id (so the same env id exists on a
|
||||
// second DB for the cross-DB union/probe cases).
|
||||
async function seedParentsWithEnvId(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
environmentId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Real PG14 (control-plane) + PG17 (run-ops) proof for the waitpoint presenter after its
|
||||
// inlined environment select was decomposed onto the ControlPlaneResolver. The waitpoint
|
||||
// scalar row lives on PG17 (run-ops); the env (apiKey/organizationId) lives on PG14
|
||||
// (control-plane), with the cross-seam Waitpoint FKs dropped. The presenter reads waitpoint
|
||||
// scalars + environmentId from run-ops and resolves the env-derived fields from control-plane.
|
||||
// The DB is never mocked; the .count() proof shows neither DB joins the other.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter resolves the env off the module-level `controlPlaneResolver` singleton, which reads
|
||||
// the `~/db.server` `prisma` singleton (split off -> controlPlanePrimary). We point that proxy at the
|
||||
// REAL control-plane container (PG14). The DB is NEVER mocked: the proxy forwards to a real client.
|
||||
const primaryHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
const lazyProxy = (holder: { client: any }, label: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (!holder.client) {
|
||||
throw new Error(`${label} not set for this test`);
|
||||
}
|
||||
return holder.client[prop];
|
||||
},
|
||||
}
|
||||
);
|
||||
const proxy = lazyProxy(primaryHolder, "primaryHolder.client");
|
||||
return {
|
||||
prisma: proxy,
|
||||
$replica: proxy,
|
||||
runOpsNewPrisma: proxy,
|
||||
sqlDatabaseSchema: Prisma.sql([`public`]),
|
||||
};
|
||||
});
|
||||
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
|
||||
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
|
||||
import { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
||||
|
||||
const WAITPOINT_CROSS_SEAM_FKS = [
|
||||
"Waitpoint_environmentId_fkey",
|
||||
"Waitpoint_projectId_fkey",
|
||||
] as const;
|
||||
|
||||
async function dropWaitpointCrossSeamFks(prisma: PrismaClient) {
|
||||
for (const c of WAITPOINT_CROSS_SEAM_FKS) {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "Waitpoint" DROP CONSTRAINT IF EXISTS "${c}"`);
|
||||
}
|
||||
}
|
||||
|
||||
let n = 0;
|
||||
async function seedControlPlane(prisma: PrismaClient) {
|
||||
const s = n++;
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${s}`, slug: `org-${s}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `P ${s}`,
|
||||
slug: `p-${s}`,
|
||||
externalRef: `proj_${s}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "PRODUCTION",
|
||||
slug: `env-${s}`,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_${s}`,
|
||||
pkApiKey: `pk_${s}`,
|
||||
shortcode: `sc_${s}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
async function seedWaitpoint(
|
||||
prisma: PrismaClient,
|
||||
ctx: { environmentId: string; projectId: string }
|
||||
) {
|
||||
const s = n++;
|
||||
return prisma.waitpoint.create({
|
||||
data: {
|
||||
id: `waitpoint_${s}_pg17`,
|
||||
friendlyId: `waitpoint_fr_${s}`,
|
||||
type: "MANUAL",
|
||||
status: "PENDING",
|
||||
idempotencyKey: `idem_${s}`,
|
||||
userProvidedIdempotencyKey: false,
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("waitpoint presenter cross-DB read-through", () => {
|
||||
heteroPostgresTest(
|
||||
"waitpoint scalars resolve from run-ops; apiKey/organizationId resolve from control-plane",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
await dropWaitpointCrossSeamFks(prisma17 as unknown as PrismaClient);
|
||||
const cp = await seedControlPlane(prisma14 as unknown as PrismaClient);
|
||||
const waitpoint = await seedWaitpoint(prisma17 as unknown as PrismaClient, {
|
||||
environmentId: cp.environment.id,
|
||||
projectId: cp.project.id,
|
||||
});
|
||||
|
||||
// Run-ops read: waitpoint scalars + environmentId, no environment relation.
|
||||
const found = await (prisma17 as unknown as PrismaClient).waitpoint.findFirst({
|
||||
where: { friendlyId: waitpoint.friendlyId, environmentId: cp.environment.id },
|
||||
select: { id: true, friendlyId: true, environmentId: true },
|
||||
});
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.environmentId).toBe(cp.environment.id);
|
||||
|
||||
// Control-plane resolution of the env-derived fields the presenter uses.
|
||||
const resolver = new ControlPlaneResolver({
|
||||
controlPlanePrimary: prisma14 as unknown as PrismaClient,
|
||||
controlPlaneReplica: prisma14 as unknown as PrismaClient,
|
||||
cache: new ControlPlaneCache(),
|
||||
splitEnabled: () => false,
|
||||
});
|
||||
const env = await resolver.resolveAuthenticatedEnv(found!.environmentId);
|
||||
expect(env).not.toBeNull();
|
||||
expect(env!.apiKey).toBe(cp.environment.apiKey);
|
||||
expect(env!.organizationId).toBe(cp.organization.id);
|
||||
|
||||
expect(await (prisma17 as unknown as PrismaClient).runtimeEnvironment.count()).toBe(0);
|
||||
expect(await (prisma14 as unknown as PrismaClient).waitpoint.count()).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"presenter returns null when env unresolvable (no false hydrate)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
await dropWaitpointCrossSeamFks(prisma17 as unknown as PrismaClient);
|
||||
const cp = await seedControlPlane(prisma14 as unknown as PrismaClient);
|
||||
// The waitpoint references an environmentId with NO row on control-plane (PG14).
|
||||
const absentEnvironmentId = `env_absent_${n++}`;
|
||||
const waitpoint = await seedWaitpoint(prisma17 as unknown as PrismaClient, {
|
||||
environmentId: absentEnvironmentId,
|
||||
projectId: cp.project.id,
|
||||
});
|
||||
|
||||
// The presenter reads waitpoint scalars off its own replica (PG17); the resolver singleton
|
||||
// reads the env off the `~/db.server` proxy -> control-plane (PG14), where it is absent.
|
||||
primaryHolder.client = prisma14;
|
||||
|
||||
const presenter = new WaitpointPresenter(
|
||||
prisma17 as unknown as PrismaClient,
|
||||
prisma17 as unknown as PrismaClient
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
friendlyId: waitpoint.friendlyId,
|
||||
environmentId: absentEnvironmentId,
|
||||
projectId: cp.project.id,
|
||||
});
|
||||
|
||||
// env resolves null after the waitpoint is found -> presenter returns null, no CH hydrate.
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter graph imports `~/services/clickhouse/clickhouseFactoryInstance.server` and, via
|
||||
// NextRunListPresenter -> RunsRepository / runStore, reaches `~/db.server`'s `prisma`/`$replica`
|
||||
// singletons (through findDisplayableEnvironment + getTaskIdentifiers). We stub those two wiring
|
||||
// boundaries so the module loads + the connected-runs hydrate can be pointed at the per-test REAL
|
||||
// containers. The DB is NEVER mocked: every assertion runs against real Postgres/ClickHouse.
|
||||
//
|
||||
// * `~/db.server` — `prisma`/`$replica`/`runOpsNewPrisma` singletons. The read-through cases pass
|
||||
// explicit client handles to the presenter ctor so these proxies are never read on those paths.
|
||||
// For the connected-runs hydrate the proxies forward lazily to the test's real legacy handle.
|
||||
// The run-ops new/legacy clients forward to the new/legacy per-test holders respectively.
|
||||
// * clickhouseFactory singleton — overridden to hand back the per-test ClickHouse so the
|
||||
// connected-runs hydrate uses a real CH container (a wiring override, not a DB mock).
|
||||
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
const newClientHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
const clickhouseHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
|
||||
vi.mock("~/db.server", async () => {
|
||||
const { Prisma } = await import("@trigger.dev/database");
|
||||
const lazyProxy = (holder: { client: any }, label: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (!holder.client) {
|
||||
throw new Error(`${label} not set for this test`);
|
||||
}
|
||||
return holder.client[prop];
|
||||
},
|
||||
}
|
||||
);
|
||||
const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client");
|
||||
return {
|
||||
prisma: replicaProxy,
|
||||
$replica: replicaProxy,
|
||||
runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"),
|
||||
runOpsNewReplica: lazyProxy(newClientHolder, "newClientHolder.client"),
|
||||
runOpsLegacyPrisma: replicaProxy,
|
||||
runOpsLegacyReplica: replicaProxy,
|
||||
sqlDatabaseSchema: Prisma.sql([`public`]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
|
||||
clickhouseFactory: {
|
||||
getClickhouseForOrganization: async () => {
|
||||
if (!clickhouseHolder.client) {
|
||||
throw new Error("clickhouseHolder.client not set for this test");
|
||||
}
|
||||
return clickhouseHolder.client;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createPostgresContainer,
|
||||
heteroPostgresTest,
|
||||
replicationContainerTest,
|
||||
} from "@internal/testcontainers";
|
||||
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
|
||||
import { PrismaClient as PrismaClientCtor } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
|
||||
import { setupClickhouseReplication } from "./utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// A read client whose waitpoint.findFirst is recorded; throws if used after being marked
|
||||
// forbidden, so we can prove a store was NEVER read. Every other access forwards to the real
|
||||
// client (so the inlined `environment` join + connectedRuns relation still resolve).
|
||||
function recording(client: PrismaClient, opts: { forbidden?: boolean } = {}) {
|
||||
const calls: unknown[] = [];
|
||||
return {
|
||||
handle: new Proxy(client, {
|
||||
get(target, prop) {
|
||||
if (prop === "waitpoint") {
|
||||
return new Proxy((target as any).waitpoint, {
|
||||
get(wpTarget, wpProp) {
|
||||
if (wpProp === "findFirst") {
|
||||
return (args: unknown) => {
|
||||
calls.push(args);
|
||||
if (opts.forbidden) {
|
||||
throw new Error("this store must never be read");
|
||||
}
|
||||
return (wpTarget as any).findFirst(args);
|
||||
};
|
||||
}
|
||||
return (wpTarget as any)[wpProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaReplicaClient,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the org/project/env parents onto a second DB with the SAME ids (FKs need them). */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_dev_${slug}_b`,
|
||||
pkApiKey: `pk_dev_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedWaitpoint(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
friendlyId: string,
|
||||
overrides: Partial<{
|
||||
type: WaitpointType;
|
||||
tags: string[];
|
||||
output: string;
|
||||
connectedRunFriendlyIds: string[];
|
||||
}> = {}
|
||||
) {
|
||||
return prisma.waitpoint.create({
|
||||
data: {
|
||||
friendlyId,
|
||||
type: overrides.type ?? "MANUAL",
|
||||
status: "COMPLETED",
|
||||
idempotencyKey: `idem-${friendlyId}`,
|
||||
userProvidedIdempotencyKey: false,
|
||||
output: overrides.output ?? JSON.stringify({ hello: "world" }),
|
||||
outputType: "application/json",
|
||||
outputIsError: false,
|
||||
completedAt: new Date(),
|
||||
tags: overrides.tags ?? ["a", "b"],
|
||||
projectId: ctx.projectId,
|
||||
environmentId: ctx.environmentId,
|
||||
...(overrides.connectedRunFriendlyIds
|
||||
? {
|
||||
connectedRuns: {
|
||||
connect: overrides.connectedRunFriendlyIds.map((friendlyId) => ({ friendlyId })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: { friendlyId: string; taskIdentifier?: string }
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: run.taskIdentifier ?? "my-task",
|
||||
status: "PENDING",
|
||||
payload: JSON.stringify({ foo: run.friendlyId }),
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "test",
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const callArgs = (ctx: SeedContext, friendlyId: string) => ({
|
||||
friendlyId,
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
});
|
||||
|
||||
describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connected runs)", () => {
|
||||
// new-DB short-circuit. Waitpoint on NEW (PG17), legacy
|
||||
// wrapped so its waitpoint.findFirst throws if invoked. The lookup answers from NEW and must
|
||||
// NEVER fall through to legacy.
|
||||
heteroPostgresTest(
|
||||
"waitpoint on the new DB resolves without touching the legacy replica",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma17, "newonly");
|
||||
const seeded = await seedWaitpoint(prisma17, ctx, "waitpoint_newonly");
|
||||
|
||||
// The env lives on the new DB here; the resolver singleton reads it through the
|
||||
// `~/db.server` proxy.
|
||||
legacyReplicaHolder.client = prisma17;
|
||||
|
||||
const newClient = recording(prisma17);
|
||||
const legacy = recording(prisma14, { forbidden: true });
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
// New-first short-circuit: legacy never probed (the throwing handle proves it).
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
// single-DB passthrough. No read-through deps -> exactly one plain
|
||||
// findFirst against the single `_replica` handle; the split branch is structurally never entered
|
||||
// (no second handle is injected). The connected-runs hydrate forwards `undefined` deps.
|
||||
heteroPostgresTest(
|
||||
"no read-through deps -> one plain findFirst on the single replica (passthrough)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "passthrough");
|
||||
const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_passthrough", {
|
||||
tags: ["one"],
|
||||
});
|
||||
|
||||
const single = recording(prisma14);
|
||||
const second = recording(prisma17, { forbidden: true });
|
||||
legacyReplicaHolder.client = single.handle;
|
||||
newClientHolder.client = second.handle;
|
||||
|
||||
// No readThroughDeps -> ctor defaults _replica to the (mocked) `$replica` singleton, which
|
||||
// forwards to `single.handle`. The split branch needs an injected second handle to fire, so
|
||||
// it cannot: passthrough is structural.
|
||||
const presenter = new WaitpointPresenter();
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
expect(result?.tags).toEqual(["one"]);
|
||||
// Exactly one read on the single client; the second handle is never touched.
|
||||
expect(single.calls.length).toBe(1);
|
||||
expect(second.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("WaitpointPresenter connected-runs hydrate routed through read-through (PG14 + PG17 + CH)", () => {
|
||||
// Waitpoint detail + connected runs resolve on run-ops NEW. The
|
||||
// waitpoint + its 2 connected runs live on the new (PG17) DB; CH gets the run id-set so the
|
||||
// threaded NextRunListPresenter hydrate returns them. Proves the read-through deps are forwarded
|
||||
// so the connected-runs hydrate flows through the routed store.
|
||||
replicationContainerTest(
|
||||
"waitpoint + 2 connected runs resolve on the new DB via the routed hydrate",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
// `prisma`/`postgresContainer` is the PG14 legacy + CH replication source. The new DB (PG17)
|
||||
// is created alongside; we seed the waitpoint + runs on it so CH replicates from PG14 — so we
|
||||
// mirror the runs onto PG14 (replication source) and the waitpoint+runs onto PG17 (authoritative).
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClientCtor({ datasources: { db: { url: newUrl } } });
|
||||
legacyReplicaHolder.client = prisma;
|
||||
newClientHolder.client = prismaNew;
|
||||
clickhouseHolder.client = clickhouse;
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "connectednew");
|
||||
await mirrorParents(prismaNew, ctx, "connectednew");
|
||||
|
||||
// The connected runs land on PG14 (CH replication source) AND on PG17 (authoritative,
|
||||
// same ids/friendlyIds) so the routed hydrate resolves them from NEW.
|
||||
const runA = await createRun(prisma, ctx, { friendlyId: "run_connA" });
|
||||
const runB = await createRun(prisma, ctx, { friendlyId: "run_connB" });
|
||||
const newRunA = await createRun(prismaNew, ctx, {
|
||||
friendlyId: "run_connA",
|
||||
taskIdentifier: "my-task-NEW",
|
||||
});
|
||||
const newRunB = await createRun(prismaNew, ctx, {
|
||||
friendlyId: "run_connB",
|
||||
taskIdentifier: "my-task-NEW",
|
||||
});
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_connA" },
|
||||
data: { id: runA.id },
|
||||
});
|
||||
await prismaNew.taskRun.update({
|
||||
where: { friendlyId: "run_connB" },
|
||||
data: { id: runB.id },
|
||||
});
|
||||
|
||||
// Waitpoint authoritative on NEW, connected to the 2 runs.
|
||||
const seeded = await seedWaitpoint(prismaNew, ctx, "waitpoint_connectednew", {
|
||||
connectedRunFriendlyIds: ["run_connA", "run_connB"],
|
||||
});
|
||||
|
||||
// Wait for CH replication so the connected-run id-set page is non-empty.
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new WaitpointPresenter(prisma, prisma, {
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
});
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
expect(result?.connectedRuns.map((r) => r.friendlyId).sort()).toEqual([
|
||||
"run_connA",
|
||||
"run_connB",
|
||||
]);
|
||||
// The connected runs carry the PG17-only taskIdentifier -> they hydrated from the threaded
|
||||
// newClient (PG17), proving the routed store is armed.
|
||||
expect(result?.connectedRuns.every((r) => r.taskIdentifier === "my-task-NEW")).toBe(true);
|
||||
void newRunA;
|
||||
void newRunB;
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("WaitpointPresenter bare-ctor production default activates readThroughRun", () => {
|
||||
heteroPostgresTest(
|
||||
"ksuid waitpoint on the new DB resolves via readThroughRun production defaults",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma17, "proddefault");
|
||||
const seeded = await seedWaitpoint(prisma17, ctx, "waitpoint_proddefault", {
|
||||
tags: ["p", "q"],
|
||||
});
|
||||
|
||||
// runOpsNewReplica default -> PG17; env resolver reads via legacyReplicaHolder.
|
||||
newClientHolder.client = prisma17;
|
||||
legacyReplicaHolder.client = prisma17;
|
||||
|
||||
// No newClient/legacyReplica injected — production ctor shape.
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, { splitEnabled: true });
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
expect(result?.tags).toEqual(["p", "q"]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
var dbClientHolder: any = undefined;
|
||||
function setDbClient(client: any) {
|
||||
dbClientHolder = client;
|
||||
}
|
||||
|
||||
vi.mock("~/db.server", () => ({
|
||||
get prisma() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
get $replica() {
|
||||
return dbClientHolder;
|
||||
},
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
WaitpointTagListPresenter,
|
||||
type TagListOptions,
|
||||
} from "~/presenters/v3/WaitpointTagListPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
type LegacySeedContext = {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
async function seedLegacyParents(prisma: PrismaClient, slug: string): Promise<LegacySeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
const env = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
return { projectId: project.id, environmentId: env.id };
|
||||
}
|
||||
|
||||
async function seedLegacyParentsWithIds(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
environmentId: string,
|
||||
projectId: string
|
||||
): Promise<void> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "PRODUCTION",
|
||||
projectId,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${slug}`,
|
||||
pkApiKey: `pk_prod_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function opts(environmentId: string, overrides: Partial<TagListOptions> = {}): TagListOptions {
|
||||
return { environmentId, ...overrides };
|
||||
}
|
||||
|
||||
describe("WaitpointTagListPresenter read-route", () => {
|
||||
postgresTest(
|
||||
"passthrough: no readRoute => _replica only, legacy handle never touched",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
const ctx = await seedLegacyParents(prisma, "pass");
|
||||
|
||||
await prisma.waitpointTag.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "ct000000000000000000001",
|
||||
name: "alpha",
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
},
|
||||
{
|
||||
id: "ct000000000000000000002",
|
||||
name: "beta",
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
},
|
||||
{
|
||||
id: "ct000000000000000000003",
|
||||
name: "gamma",
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const legacyThrows = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("legacy handle must not be touched in passthrough");
|
||||
},
|
||||
}
|
||||
) as unknown as PrismaClient;
|
||||
|
||||
const presenter = new WaitpointTagListPresenter(prisma, prisma, {
|
||||
runOpsLegacyReplica: legacyThrows,
|
||||
});
|
||||
const result = await presenter.call(opts(ctx.environmentId, { pageSize: 10 }));
|
||||
|
||||
expect(result.tags.map((t) => t.name)).toEqual(["gamma", "beta", "alpha"]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.currentPage).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"split merge: tags from NEW and LEGACY are deduped and ordered id desc",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
setDbClient(prisma14);
|
||||
|
||||
const envId = "env0000000000000000001";
|
||||
const projId = "proj000000000000000001";
|
||||
|
||||
await seedLegacyParentsWithIds(prisma14, "merge14", envId, projId);
|
||||
|
||||
await (prisma17 as RunOpsPrismaClient).waitpointTag.createMany({
|
||||
data: [
|
||||
{ id: "mt000000000000000000005", name: "echo", environmentId: envId, projectId: projId },
|
||||
{ id: "mt000000000000000000004", name: "delta", environmentId: envId, projectId: projId },
|
||||
],
|
||||
});
|
||||
|
||||
await (prisma14 as PrismaClient).waitpointTag.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "mt000000000000000000004",
|
||||
name: "delta-stale",
|
||||
environmentId: envId,
|
||||
projectId: projId,
|
||||
},
|
||||
{
|
||||
id: "mt000000000000000000003",
|
||||
name: "charlie",
|
||||
environmentId: envId,
|
||||
projectId: projId,
|
||||
},
|
||||
{ id: "mt000000000000000000002", name: "bravo", environmentId: envId, projectId: projId },
|
||||
{ id: "mt000000000000000000001", name: "alpha", environmentId: envId, projectId: projId },
|
||||
],
|
||||
});
|
||||
|
||||
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const result = await presenter.call(opts(envId, { pageSize: 4 }));
|
||||
|
||||
expect(result.tags.map((t) => t.name)).toEqual(["echo", "delta", "charlie", "bravo"]);
|
||||
expect(result.hasMore).toBe(true);
|
||||
|
||||
const dupes = result.tags.filter((t) => t.name === "delta-stale");
|
||||
expect(dupes).toHaveLength(0);
|
||||
expect(result.tags.filter((t) => t.name === "delta")).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"offset window: page 2 returns the correct slice of the merged prefix",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
setDbClient(prisma14);
|
||||
|
||||
const envId = "env0000000000000000002";
|
||||
const projId = "proj000000000000000002";
|
||||
|
||||
await seedLegacyParentsWithIds(prisma14, "page214", envId, projId);
|
||||
|
||||
await (prisma17 as RunOpsPrismaClient).waitpointTag.createMany({
|
||||
data: [
|
||||
{ id: "pt000000000000000000006", name: "f", environmentId: envId, projectId: projId },
|
||||
{ id: "pt000000000000000000005", name: "e", environmentId: envId, projectId: projId },
|
||||
],
|
||||
});
|
||||
await (prisma14 as PrismaClient).waitpointTag.createMany({
|
||||
data: [
|
||||
{ id: "pt000000000000000000004", name: "d", environmentId: envId, projectId: projId },
|
||||
{ id: "pt000000000000000000003", name: "c", environmentId: envId, projectId: projId },
|
||||
{ id: "pt000000000000000000002", name: "b", environmentId: envId, projectId: projId },
|
||||
{ id: "pt000000000000000000001", name: "a", environmentId: envId, projectId: projId },
|
||||
],
|
||||
});
|
||||
|
||||
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
|
||||
const page2 = await presenter.call(opts(envId, { pageSize: 2, page: 2 }));
|
||||
expect(page2.tags.map((t) => t.name)).toEqual(["d", "c"]);
|
||||
expect(page2.hasMore).toBe(true);
|
||||
expect(page2.currentPage).toBe(2);
|
||||
|
||||
const page3 = await presenter.call(opts(envId, { pageSize: 2, page: 3 }));
|
||||
expect(page3.tags.map((t) => t.name)).toEqual(["b", "a"]);
|
||||
expect(page3.hasMore).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user