diff --git a/.server-changes/route-run-presenter-reads-through-run-store.md b/.server-changes/route-run-presenter-reads-through-run-store.md new file mode 100644 index 0000000000..962e72f95f --- /dev/null +++ b/.server-changes/route-run-presenter-reads-through-run-store.md @@ -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. diff --git a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts index f175429c14..6d46074e72 100644 --- a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts @@ -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 { 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( + const splitEnabled = this.readThrough?.splitEnabled ?? false; + + if (!splitEnabled) { + return this.#callPassthrough(friendlyId, env); + } + + 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 { + const batchRun = await this._replica.batchTaskRun.findFirst({ + where: { friendlyId, - env.id, - { - include: { - items: { - select: { - taskRunId: true, - }, - }, + runtimeEnvironmentId: env.id, + }, + include: { + items: { + select: { + taskRunId: true, }, }, - this._prisma - ); + }, + }); - if (!batchRun) { - return undefined; - } + if (!batchRun) { + return undefined; + } - const taskRunIds = batchRun.items.map((item) => item.taskRunId); + const taskRunIds = batchRun.items.map((item) => item.taskRunId); - if (taskRunIds.length === 0) { - return { - id: batchRun.friendlyId, - items: [], - }; - } + 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", - }, + 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 { + // 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, }, }, }, - this._prisma - ); + }); + + 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); + } - const runMap = new Map(taskRuns.map((run) => [run.id, run])); + if (!batchRun) { + return undefined; + } + if (batchRun.items.length === 0) { return { id: batchRun.friendlyId, - items: batchRun.items - .map((item) => { - const run = runMap.get(item.taskRunId); - return run ? executionResultForTaskRun(run as TaskRunWithAttempts) : undefined; - }) - .filter(Boolean), + items: [], }; - }); + } + + const readMemberRun = (client: PrismaClientOrTransaction, taskRunId: string) => + client.taskRun.findFirst({ + where: { id: taskRunId }, + select: memberRunSelect, + }) as Promise; + + // 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({ + 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), + }; } } diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index f5a22b7bd6..a5c71a5555 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -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) 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(); + 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( + 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 diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index b49f239fea..2be8458fa0 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -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; export class ApiRunListPresenter extends BasePresenter { + constructor( + prismaClient?: PrismaClientOrTransaction, + replicaClient?: PrismaClientOrTransaction, + private readonly readThroughDeps?: ApiRunListPresenterReadThroughDeps + ) { + super(prismaClient, replicaClient); + } + public async call( project: Pick, 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 }); diff --git a/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts index bfa76c5fc4..1fef986600 100644 --- a/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts @@ -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 { return this.traceWithEnv("call", env, async (span) => { - const taskRun = await runStore.findRun( - { - friendlyId, - runtimeEnvironmentId: env.id, - }, - { - include: { - attempts: { - orderBy: { - createdAt: "desc", - }, - }, - }, + 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, }, - this._prisma - ); + }); + + const taskRun = + result.source === "new" || result.source === "legacy-replica" ? result.value : undefined; if (!taskRun) { return undefined; diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts index 2b7612094c..a9fd5a287f 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointListPresenter.server.ts @@ -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; 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) { diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts index 7d97f6f681..c42371f632 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -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, + }, + 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, }, - 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, diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 8f14ee75ee..8896b597fe 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -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>; 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 + ): Promise { + 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(); + 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 { + // 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 - } - -- 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 - } - ${ - periodMs - ? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` - : Prisma.empty + let createdAtGte: Date | undefined; + if (periodMs != null) { + createdAtGte = new Date(Date.now() - periodMs); } - ${ - time.from - ? Prisma.sql`AND b."createdAt" >= ${time.from.toISOString()}::timestamp` - : Prisma.empty + if (time.from !== undefined) { + createdAtGte = + createdAtGte === undefined + ? time.from + : time.from > createdAtGte + ? time.from + : createdAtGte; } - ${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 { diff --git a/apps/webapp/app/presenters/v3/BatchPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchPresenter.server.ts index 6fd504f89b..e0eb3a24fd 100644 --- a/apps/webapp/app/presenters/v3/BatchPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchPresenter.server.ts @@ -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>; 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 => + client.batchTaskRun.findFirst({ select: BATCH_SELECT, where }); + + const readThrough = this.deps.readThrough ?? readThroughRun; + const batchResult = await readThrough({ + // 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, diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index bfd94b1ac9..b030f5a3f1 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -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 { + 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 { diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 87619a1e7c..18557e5173 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -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>; 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, diff --git a/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts b/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts index f9b1317334..9554e9619b 100644 --- a/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts @@ -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(); diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 348b2bb33c..ea2a89eaa1 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -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): 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` — 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`, 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["getRun"]>> >; @@ -94,6 +93,10 @@ export type Span = NonNullable["span"]>; type FindRunResult = NonNullable< Awaited["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 { 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; diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 2532f466e5..430477ce58 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -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 & { 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 { + 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 { + 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) { diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index 3fcff6831d..24d18a76e4 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -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, + pageSize: number, + direction: Direction + ): Promise { + 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(); + 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 { + 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( diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index cecba25c16..a56c5e76ad 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -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>>; 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, diff --git a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts index d2853a10ca..6767e2855b 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts @@ -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>; export type TagListItem = TagList["tags"][number]; +type WaitpointTagRow = { + id: string; + name: string; +}; + +type TagFindManyArgs = NonNullable< + Parameters[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, - }, - orderBy: { - id: "desc", + name: name ? { startsWith: name, mode: "insensitive" } : undefined, }, - 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 { + 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(); + 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); + } } diff --git a/apps/webapp/test/SpanPresenter.readthrough.test.ts b/apps/webapp/test/SpanPresenter.readthrough.test.ts new file mode 100644 index 0000000000..6cf625e74f --- /dev/null +++ b/apps/webapp/test/SpanPresenter.readthrough.test.ts @@ -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 { + 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 { + 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"); + } + ); +}); diff --git a/apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts b/apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts new file mode 100644 index 0000000000..11c97dc270 --- /dev/null +++ b/apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts @@ -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>; + +// 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(); + } + ); +}); diff --git a/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts new file mode 100644 index 0000000000..4b0957767a --- /dev/null +++ b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts @@ -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>; + +// 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(); + }); +}); diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts new file mode 100644 index 0000000000..48e1bcc785 --- /dev/null +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -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 { + const pgRow = (await store.findRun( + { friendlyId, runtimeEnvironmentId }, + { select: findRunSelect } + )) as Record | null; + if (!pgRow) return null; + const foldVersion = (run: Record) => ({ ...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]); + } + ); +}); diff --git a/apps/webapp/test/apiRunListPresenter.test.ts b/apps/webapp/test/apiRunListPresenter.test.ts new file mode 100644 index 0000000000..bc9826461b --- /dev/null +++ b/apps/webapp/test/apiRunListPresenter.test.ts @@ -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 { + 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 { + 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 { + 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"); + } + ); +}); diff --git a/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts new file mode 100644 index 0000000000..3b7571f4f5 --- /dev/null +++ b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts @@ -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 { + 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"); + } + } + ); +}); diff --git a/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts b/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts new file mode 100644 index 0000000000..a9dfb1a6e4 --- /dev/null +++ b/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts @@ -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([]); + } + ); +}); diff --git a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts new file mode 100644 index 0000000000..58d3827f55 --- /dev/null +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -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); + } + ); +}); diff --git a/apps/webapp/test/batchListPresenter.readroute.test.ts b/apps/webapp/test/batchListPresenter.readroute.test.ts new file mode 100644 index 0000000000..ee0db66dde --- /dev/null +++ b/apps/webapp/test/batchListPresenter.readroute.test.ts @@ -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 { + 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 { + 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 => ({ + 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"); + } + ); +}); diff --git a/apps/webapp/test/batchPresenter.test.ts b/apps/webapp/test/batchPresenter.test.ts new file mode 100644 index 0000000000..1547d45201 --- /dev/null +++ b/apps/webapp/test/batchPresenter.test.ts @@ -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 { + 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); + } + ); +}); diff --git a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts new file mode 100644 index 0000000000..41daf9834d --- /dev/null +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -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 { + 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 { + 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(); + } + } + ); +}); diff --git a/apps/webapp/test/presenters/ApiBatchResultsPresenter.split.test.ts b/apps/webapp/test/presenters/ApiBatchResultsPresenter.split.test.ts deleted file mode 100644 index 4a75cf281d..0000000000 --- a/apps/webapp/test/presenters/ApiBatchResultsPresenter.split.test.ts +++ /dev/null @@ -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(); - } -); diff --git a/apps/webapp/test/presenters/ApiBatchResultsPresenter.test.ts b/apps/webapp/test/presenters/ApiBatchResultsPresenter.test.ts index 93c4fc59d4..8324bbbd75 100644 --- a/apps/webapp/test/presenters/ApiBatchResultsPresenter.test.ts +++ b/apps/webapp/test/presenters/ApiBatchResultsPresenter.test.ts @@ -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: [] }); diff --git a/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts b/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts new file mode 100644 index 0000000000..d4c92b7c41 --- /dev/null +++ b/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts @@ -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 { + 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); + } + } + ); +}); diff --git a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts new file mode 100644 index 0000000000..f4fc652ef2 --- /dev/null +++ b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts @@ -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 { + 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 { + 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(); + } + } + ); +}); diff --git a/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts new file mode 100644 index 0000000000..9741d7f4a0 --- /dev/null +++ b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts @@ -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 { + 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]); + } + ); +}); diff --git a/apps/webapp/test/runPresenterReadRoute.test.ts b/apps/webapp/test/runPresenterReadRoute.test.ts new file mode 100644 index 0000000000..e3733ff8f2 --- /dev/null +++ b/apps/webapp/test/runPresenterReadRoute.test.ts @@ -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(); + + 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); + }); +}); diff --git a/apps/webapp/test/setup.ts b/apps/webapp/test/setup.ts index 5824c15b90..4da9d260aa 100644 --- a/apps/webapp/test/setup.ts +++ b/apps/webapp/test/setup.ts @@ -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() })); diff --git a/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts b/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts new file mode 100644 index 0000000000..bbac387200 --- /dev/null +++ b/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts @@ -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); + } + ); +}); diff --git a/apps/webapp/test/waitpointListPresenter.readroute.test.ts b/apps/webapp/test/waitpointListPresenter.readroute.test.ts new file mode 100644 index 0000000000..49cf874909 --- /dev/null +++ b/apps/webapp/test/waitpointListPresenter.readroute.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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}`, + }, + }); +} diff --git a/apps/webapp/test/waitpointPresenter.controlPlane.test.ts b/apps/webapp/test/waitpointPresenter.controlPlane.test.ts new file mode 100644 index 0000000000..f3513252ed --- /dev/null +++ b/apps/webapp/test/waitpointPresenter.controlPlane.test.ts @@ -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(); + } + ); +}); diff --git a/apps/webapp/test/waitpointPresenter.readthrough.test.ts b/apps/webapp/test/waitpointPresenter.readthrough.test.ts new file mode 100644 index 0000000000..4cb5809233 --- /dev/null +++ b/apps/webapp/test/waitpointPresenter.readthrough.test.ts @@ -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 { + 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 { + 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"]); + } + ); +}); diff --git a/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts new file mode 100644 index 0000000000..ced2648c8b --- /dev/null +++ b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts @@ -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 { + 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 { + 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 { + 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); + } + ); +});