From c532b8be31dcc0dcf2ce32a9a61ad503d549c811 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Wed, 1 Jul 2026 16:20:02 +0100 Subject: [PATCH 01/14] =?UTF-8?q?feat(run-ops):=20read=20presenters=20?= =?UTF-8?q?=E2=80=94=20de-join=20control-plane=20relations=20+=20read-thro?= =?UTF-8?q?ugh=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/ApiBatchResultsPresenter.server.ts | 245 +++++-- .../v3/ApiRetrieveRunPresenter.server.ts | 93 ++- .../v3/ApiRunListPresenter.server.ts | 20 +- .../v3/ApiRunResultPresenter.server.ts | 60 +- .../v3/ApiWaitpointListPresenter.server.ts | 15 +- .../v3/ApiWaitpointPresenter.server.ts | 99 ++- .../v3/BatchListPresenter.server.ts | 226 ++++-- .../presenters/v3/BatchPresenter.server.ts | 140 ++-- .../v3/NextRunListPresenter.server.ts | 65 +- .../app/presenters/v3/RunPresenter.server.ts | 92 +-- .../v3/RunStreamPresenter.server.ts | 39 +- .../app/presenters/v3/SpanPresenter.server.ts | 137 ++-- .../v3/TaskDetailPresenter.server.ts | 2 + .../presenters/v3/TestTaskPresenter.server.ts | 139 +++- .../v3/WaitpointListPresenter.server.ts | 256 ++++--- .../v3/WaitpointPresenter.server.ts | 151 ++-- .../v3/WaitpointTagListPresenter.server.ts | 82 ++- .../test/SpanPresenter.readthrough.test.ts | 547 +++++++++++++++ ...iBatchResultsPresenter.readthrough.test.ts | 485 +++++++++++++ .../apiRetrieveRunPresenter.readroute.test.ts | 615 +++++++++++++++++ apps/webapp/test/apiRunListPresenter.test.ts | 407 +++++++++++ .../apiRunResultPresenter.readthrough.test.ts | 431 ++++++++++++ ...piWaitpointListPresenter.readroute.test.ts | 116 ++++ .../apiWaitpointPresenter.readthrough.test.ts | 324 +++++++++ .../test/batchListPresenter.readroute.test.ts | 466 +++++++++++++ apps/webapp/test/batchPresenter.test.ts | 372 ++++++++++ .../nextRunListPresenter.readthrough.test.ts | 416 +++++++++++ .../ApiBatchResultsPresenter.test.ts | 12 +- .../TaskDetailPresenter.getActivity.test.ts | 159 +++++ .../TestTaskPresenter.readthrough.test.ts | 647 ++++++++++++++++++ .../clickHouseRunListResolver.test.ts | 384 +++++++++++ .../webapp/test/runPresenterReadRoute.test.ts | 282 ++++++++ .../spanPresenterReadthroughDecompose.test.ts | 151 ++++ .../waitpointListPresenter.readroute.test.ts | 474 +++++++++++++ .../waitpointPresenter.controlPlane.test.ts | 170 +++++ .../waitpointPresenter.readthrough.test.ts | 555 +++++++++++++++ ...aitpointTagListPresenter.readroute.test.ts | 213 ++++++ 37 files changed, 8517 insertions(+), 570 deletions(-) create mode 100644 apps/webapp/test/SpanPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts create mode 100644 apps/webapp/test/apiRunListPresenter.test.ts create mode 100644 apps/webapp/test/apiRunResultPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts create mode 100644 apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/batchListPresenter.readroute.test.ts create mode 100644 apps/webapp/test/batchPresenter.test.ts create mode 100644 apps/webapp/test/nextRunListPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts create mode 100644 apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/realtime/clickHouseRunListResolver.test.ts create mode 100644 apps/webapp/test/runPresenterReadRoute.test.ts create mode 100644 apps/webapp/test/spanPresenterReadthroughDecompose.test.ts create mode 100644 apps/webapp/test/waitpointListPresenter.readroute.test.ts create mode 100644 apps/webapp/test/waitpointPresenter.controlPlane.test.ts create mode 100644 apps/webapp/test/waitpointPresenter.readthrough.test.ts create mode 100644 apps/webapp/test/waitpointTagListPresenter.readroute.test.ts diff --git a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts index f175429c147..73120185236 100644 --- a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts @@ -1,81 +1,222 @@ 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 { isKnownMigrated as defaultIsKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.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; + isKnownMigrated?: (runId: string) => Promise; + 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, + isKnownMigrated: this.readThrough?.isKnownMigrated ?? defaultIsKnownMigrated, + 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 f5a22b7bd67..a5c71a55551 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 b49f239fea7..2be8458fa0b 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 bfa76c5fc4f..320b0eba004 100644 --- a/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts @@ -1,31 +1,59 @@ 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; + isKnownMigrated?: (runId: string) => Promise; + 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 only for runs not known-migrated; 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), + isKnownMigrated: this._readThrough?.isKnownMigrated, + 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 2b7612094cd..a9fd5a287fd 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 7d97f6f6813..8f011a03545 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -4,8 +4,30 @@ 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"; +import { isKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server"; + +// When omitted, clients default to the inherited _replica handle => passthrough reads the +// replica exactly as today. Pure boundaries (isKnownMigrated/isPastRetention) are injectable +// for tests. Typed PrismaReplicaClient to match readThroughRun's readNew/readLegacy + deps. +type ApiWaitpointPresenterReadThroughDeps = { + newClient?: PrismaReplicaClient; + legacyReplica?: PrismaReplicaClient; + splitEnabled?: boolean; + isKnownMigrated?: (id: string) => Promise; + 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 +41,63 @@ 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, + // Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY + // RUN-OPS READ REPLICA ONLY for ids not known-migrated — 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, + connectedRuns: { + select: { + friendlyId: true, + }, + take: 5, }, - take: 5, + 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), + isKnownMigrated: this.readThroughDeps?.isKnownMigrated ?? isKnownMigrated, + 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 8f14ee75ee4..2ed5abb5303 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,108 @@ 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 { + const onNew = await (this.readRoute?.runOpsNew ?? this._replica).batchTaskRun.findFirst({ + where: { runtimeEnvironmentId: environmentId }, + }); + if (onNew) { + return true; + } + + if (!this.readRoute?.splitEnabled) { + return false; + } + + const onLegacy = await ( + this.readRoute?.runOpsLegacyReplica ?? this._replica + ).batchTaskRun.findFirst({ + where: { runtimeEnvironmentId: environmentId }, + }); + return Boolean(onLegacy); + } + public async call({ userId, projectId, @@ -53,8 +154,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 +183,58 @@ 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 6fd504f89b4..e0eb3a24fd1 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 bfd94b1ac9a..b030f5a3f12 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 87619a1e7c8..18557e51738 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 f9b1317334e..9554e9619b4 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 348b2bb33c9..dde88ccf373 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -36,6 +36,12 @@ import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/tas import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; +import type { SpanDetail } from "~/v3/eventRepository/eventRepository.types"; +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 +82,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 +94,11 @@ export type Span = NonNullable["span"]>; type FindRunResult = NonNullable< Awaited["findRun"]>> >; +type GetSpanResult = SpanDetail; + +// 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 +256,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 +307,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 +322,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 +342,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 +398,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 +533,9 @@ export class SpanPresenter extends BasePresenter { { select: { id: true, + runtimeEnvironmentId: true, + lockedById: true, + lockedToVersionId: true, spanId: true, traceId: true, traceContext: true, @@ -523,14 +548,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 +584,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 +724,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 +971,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 +992,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 +1018,7 @@ export class SpanPresenter extends BasePresenter { }, task: { id: run.taskIdentifier, - filePath: run.lockedBy?.filePath ?? "", + filePath: lockedWorker?.lockedBy?.filePath ?? "", }, run: { id: run.friendlyId, @@ -1015,7 +1032,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 +1040,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/TaskDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts index d4bd38cf643..53e690c1191 100644 --- a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts @@ -141,6 +141,8 @@ export class TaskDetailPresenter { }; } + // SPLIT-NEUTRAL: served entirely from ClickHouse (task_runs_v2); + // no run-ops Postgres read — single-DB behavior is n-a, RoutingRunStore is not involved. async getActivity({ organizationId, projectId, diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 2532f466e53..d8bc1ade64a 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,50 @@ 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 { isKnownMigrated as defaultIsKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.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; + isKnownMigrated?: (runId: string) => Promise; +}; + +// 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 +160,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 +255,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 +392,65 @@ 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 not known-migrated — + // 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 isKnownMigrated = this.readThrough.isKnownMigrated ?? defaultIsKnownMigrated; + + const newRows = await this.hydrateOnClient(newClient, runIds); + const foundIds = new Set(newRows.map((r) => r.id)); + const missing = runIds.filter((id) => !foundIds.has(id)); + + const toProbeLegacy: string[] = []; + for (const id of missing) { + if (!(await isKnownMigrated(id))) { + toProbeLegacy.push(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 3fcff6831d1..dcd3220b509 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,61 @@ 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 +250,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 +288,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 cecba25c169..f40dcfe6eef 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -1,7 +1,16 @@ import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3"; +import { + type PrismaClientOrTransaction, + type PrismaReplicaClient, + runOpsNewReplica, + runOpsLegacyReplica, +} 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 { isKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.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 +18,75 @@ 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; + isKnownMigrated?: (id: string) => Promise; + } + ) { + 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) ?? runOpsNewReplica, + legacyReplica: + (this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ?? + runOpsLegacyReplica, + isKnownMigrated: this.readThroughDeps.isKnownMigrated ?? isKnownMigrated, + }, + }); + + return result.source === "new" || result.source === "legacy-replica" ? result.value : null; + } + public async call({ friendlyId, environmentId, @@ -18,41 +96,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 +105,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 +131,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 +158,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 d2853a10cad..6767e2855bc 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 00000000000..31aabcc2c00 --- /dev/null +++ b/apps/webapp/test/SpanPresenter.readthrough.test.ts @@ -0,0 +1,547 @@ +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: { current: unknown } = { current: undefined }; +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, receiver) { + 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)(...stripped); + }; + } + return Reflect.get(target, prop, receiver); + }, + }) 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.readthrough.test.ts b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts new file mode 100644 index 00000000000..9349525db6b --- /dev/null +++ b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts @@ -0,0 +1,485 @@ +// 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 / isKnownMigrated / 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; + +const neverMigrated = async () => false; + +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, + isKnownMigrated: neverMigrated, + }); + + 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, + isKnownMigrated: neverMigrated, + }); + + 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" }); + } + ); + + // A known-migrated member is not re-probed against the legacy replica. + heteroPostgresTest( + "a known-migrated member missing from the new probe is NOT re-probed on legacy", + async ({ prisma14, prisma17 }) => { + const newDb = prisma17 as unknown as PrismaClient; + const legacyDb = prisma14 as unknown as PrismaClient; + + const ctx = await seedEnv(newDb, "mig-new"); + await mirrorEnv(legacyDb, ctx, "mig-legacy"); + await relaxFks(newDb); + await relaxFks(legacyDb); + + // A legacy-classified id (so the LEGACY fan-out branch is reachable) that is "known migrated". + const migratedId = legacyRunId("d"); + // Seed the member ONLY on legacy, but mark it known-migrated → legacy must NOT be probed, + // so it resolves to not-found and is omitted. + await seedMember(legacyDb, ctx, { + id: migratedId, + friendlyId: "run_migrated_d", + status: "COMPLETED_SUCCESSFULLY", + output: JSON.stringify({}), + }); + await seedBatch(newDb, ctx, "batch_mig", [migratedId]); + + // Spy legacy replica: throw if the member-hydrate findFirst runs for the migrated id. + const legacySpy = new Proxy(prisma14, { + get(target, prop) { + if (prop === "taskRun") { + return new Proxy((target as any).taskRun, { + get(trTarget, trProp) { + if (trProp === "findFirst") { + return async (args: any) => { + if (args?.where?.id === migratedId) { + throw new Error( + "legacy replica must not be probed for a known-migrated member" + ); + } + return (trTarget as any).findFirst(args); + }; + } + return (trTarget as any)[trProp]; + }, + }); + } + return (target as any)[prop]; + }, + }) as unknown as PrismaReplicaClient; + + const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, { + splitEnabled: true, + newClient: prisma17 as unknown as PrismaReplicaClient, + legacyReplica: legacySpy, + isKnownMigrated: async (id) => id === migratedId, + }); + + const result = await presenter.call("batch_mig", env(ctx)); + + expect(result).toBeDefined(); + // Known-migrated + missing from NEW → not-found → omitted, without probing legacy. + expect(result!.items).toHaveLength(0); + } + ); + + // 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, + isKnownMigrated: neverMigrated, + }); + + 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, + isKnownMigrated: neverMigrated, + }); + + 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 boundaries: if the split path is entered, these blow up. + const presenter = new ApiBatchResultsPresenter( + prisma, + prisma, + { + splitEnabled: false, + legacyReplica: throwingPrisma, + isKnownMigrated: async () => { + throw new Error("isKnownMigrated must not be invoked on the passthrough path"); + }, + }, + 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 00000000000..9522307d499 --- /dev/null +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -0,0 +1,615 @@ +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, + lockedToVersion: { select: { version: 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, mapping to FoundRun. +async function readFoundRunViaStore( + store: PostgresRunStore, + friendlyId: string, + runtimeEnvironmentId: string +): Promise { + const pgRow = await store.findRun( + { friendlyId, runtimeEnvironmentId }, + { select: findRunSelect } + ); + if (!pgRow) return null; + return { ...(pgRow as Omit), isBuffered: false }; +} + +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 the DoD +// asserts round-trips. `seedTestRun.ts` only seeds a single root run, so the +// tree + attempt rows are created inline here (per the plan's seeding note). +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 00000000000..435855d0b33 --- /dev/null +++ b/apps/webapp/test/apiRunListPresenter.test.ts @@ -0,0 +1,407 @@ +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"); + return { + prisma: replicaProxy, + $replica: replicaProxy, + runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"), + 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)", () => { + // Step 1 + Step 6 (e2e #6): 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(); + } + } + ); + + // Step 2: 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(); + } + } + ); + + // Step 3 / DoD "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"]); + } + ); + + // Step 4 / DoD "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 00000000000..ff0b141bd0c --- /dev/null +++ b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts @@ -0,0 +1,431 @@ +// 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 only for not-known-migrated runs (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/isKnownMigrated/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, + isKnownMigrated: async () => false, + } + ); + + 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"); + } + } + ); + + // A legacy-classified id that lives on the new DB: the new read hits it, so the known-migrated + // short-circuit means the legacy replica is never probed. + heteroPostgresTest( + "split: a known-migrated run is served from new and the legacy replica is never probed for it", + async ({ prisma14, prisma17 }) => { + const friendlyId = legacyFriendlyId(); + const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "migrated"); + await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, { + status: "COMPLETED_SUCCESSFULLY", + attempt: { status: "COMPLETED", output: '"on-new"', 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(), + isKnownMigrated: async () => true, + } + ); + + 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('"on-new"'); + } + } + ); + + // 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, + isKnownMigrated: async () => false, + 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; legacy + isKnownMigrated are never invoked", + 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" }, + }); + + const throwingFilter = vi.fn(async () => { + throw new Error("isKnownMigrated must never run in single-DB mode"); + }); + + // 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"'); + } + expect(throwingFilter).not.toHaveBeenCalled(); + + // splitEnabled:false with throwing legacy + filter 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(), + isKnownMigrated: throwingFilter, + } + ); + const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId)); + expect(result2?.ok).toBe(true); + expect(throwingFilter).not.toHaveBeenCalled(); + } + ); + + // 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 00000000000..cf8e8720bdb --- /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 { 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 00000000000..5087c61d473 --- /dev/null +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -0,0 +1,324 @@ +// 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, isKnownMigrated, isPastRetention) and recording client wrappers are +// injected. heteroPostgresTest runs the legacy and new databases on different major versions. +import { heteroPostgresTest, postgresTest } from "@internal/testcontainers"; +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, 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, + isKnownMigrated: async () => false, + }); + + 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, + isKnownMigrated: async () => false, + }); + + 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( + "a known-migrated waitpoint is not re-probed against legacy", + async ({ prisma17, prisma14 }) => { + // Legacy-residency id (forces the new-first-then-legacy fan-out), but the new probe + // misses (simulated lag: empty NEW DB) and isKnownMigrated returns true → no legacy probe. + const id = generateLegacyCuid(); + + const { project, environment } = await seedOrgProjectEnv(prisma14, "migrated"); + + const newClient = recording(prisma17); + const legacy = recording(prisma14, { forbidden: true }); + + const presenter = new ApiWaitpointPresenter(undefined, undefined, { + splitEnabled: true, + newClient: newClient.handle, + legacyReplica: legacy.handle, + isKnownMigrated: async () => true, + }); + + await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow( + "Waitpoint not found" + ); + + expect(newClient.calls.length).toBe(1); + // known-migrated short-circuit: legacy never probed. + expect(legacy.calls.length).toBe(0); + } + ); + + heteroPostgresTest( + "not-found maps to the existing ServiceValidationError surface", + async ({ prisma17, prisma14 }) => { + const id = generateLegacyCuid(); + const { project, environment } = await seedOrgProjectEnv(prisma14, "nf"); + + const presenter = new ApiWaitpointPresenter(undefined, undefined, { + splitEnabled: true, + newClient: recording(prisma17).handle, + legacyReplica: recording(prisma14).handle, + isKnownMigrated: async () => false, + }); + + 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 { project, environment } = await seedOrgProjectEnv(prisma14, "pr"); + + const presenter = new ApiWaitpointPresenter(undefined, undefined, { + splitEnabled: true, + newClient: recording(prisma17).handle, + legacyReplica: recording(prisma14).handle, + isKnownMigrated: async () => false, + isPastRetention: () => true, + }); + + await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow( + "Waitpoint not found" + ); + } + ); + + heteroPostgresTest( + "cross-seam — migrated served from NEW (legacy untouched); in-retention served from legacy", + async ({ prisma17, prisma14 }) => { + // Migrated waitpoint: lives on NEW, isKnownMigrated true, 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, + isKnownMigrated: async () => true, + }); + 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, + isKnownMigrated: async () => false, + }); + const retentionResult = await retentionPresenter.call(environmentArg(oldEnv.environment), oldId); + expect(retentionResult.id).toBe(`waitpoint_${oldId}`); + } + ); +}); + +describe("ApiWaitpointPresenter passthrough (single-DB)", () => { + postgresTest( + "no read-through deps → one plain replica read; legacy + isKnownMigrated never invoked", + 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); + let knownMigratedInvoked = false; + 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 + isKnownMigrated must never fire. + const presenter = new ApiWaitpointPresenter(undefined, undefined, { + newClient: single.handle, + legacyReplica: legacy.handle, + isKnownMigrated: async () => { + knownMigratedInvoked = true; + return false; + }, + }); + + 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 + known-migrated untouched. + expect(single.calls.length).toBe(1); + expect(legacy.calls.length).toBe(0); + expect(knownMigratedInvoked).toBe(false); + } + ); +}); diff --git a/apps/webapp/test/batchListPresenter.readroute.test.ts b/apps/webapp/test/batchListPresenter.readroute.test.ts new file mode 100644 index 00000000000..28ca158afa2 --- /dev/null +++ b/apps/webapp/test/batchListPresenter.readroute.test.ts @@ -0,0 +1,466 @@ +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 { PrismaClient } from "@trigger.dev/database"; +import { 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)", () => { + // --- Task 3 Step 1: 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)); + } + ); + + // --- Task 3 Step 2: 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); + } + ); + + // --- Task 3 Step 3: 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); + } + ); + + // --- Task 3 Step 4: 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); + } + ); + + // --- Task 3 Step 5: 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); + } + ); + + // RED before fix: $queryRaw across clients → P2010/42601 "$1". GREEN after: typed findMany. + 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 00000000000..fada97a1b51 --- /dev/null +++ b/apps/webapp/test/batchPresenter.test.ts @@ -0,0 +1,372 @@ +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); + }; +} + +/** + * Wraps the REAL readThroughRun but forces isKnownMigrated false so the layer is hermetic + * (the default isKnownMigrated would import ~/db.server and probe an ambient .env DB the test + * never seeded). Pure boundary injection — the DB reads still hit the real containers. + */ +function hermeticReadThrough(isKnownMigrated: (id: string) => Promise = async () => false) { + return (input: Parameters>[0]) => + readThroughRun({ + ...input, + deps: { ...input.deps, isKnownMigrated }, + }); +} + +describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { + // DoD: 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: hermeticReadThrough(), + 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"); + } + ); + + // DoD: 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; isKnownMigrated forced false (no marker) so it falls to legacy. + readThrough: hermeticReadThrough(), + 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); + } + ); + + // DoD: 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: hermeticReadThrough(), + resolveDisplayableEnvironment: makeEnvResolver(prisma14), + }); + + await expect( + presenter.call({ environmentId: ctx.environmentId, batchId: "batch_does_not_exist" }) + ).rejects.toThrow("Batch not found"); + } + ); + + // DoD: 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: hermeticReadThrough(), + 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", () => { + // DoD passthrough line + 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 00000000000..0622a0fb8ea --- /dev/null +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -0,0 +1,416 @@ +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"); + return { + prisma: replicaProxy, + $replica: replicaProxy, + runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"), + 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) => ({ projectId: ctx.projectId, pageSize: 10 }); + +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)); + + // 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)); + + 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. + // The presenter cannot supply isKnownMigrated, so the routed hydrate runs without it; we assert + // the rows that DO surface (the full union, since legacy is probed for legacy-only ids). + // 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.test.ts b/apps/webapp/test/presenters/ApiBatchResultsPresenter.test.ts index 93c4fc59d49..8324bbbd757 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 00000000000..013d8615e3f --- /dev/null +++ b/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts @@ -0,0 +1,159 @@ +// 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 => 1h buckets => 6 buckets. + const from = new Date("2026-01-01T00:00:00Z"); + const to = new Date("2026-01-01T06:00:00Z"); + + // Bucket 0 (00:00–01:00): 1 COMPLETED, 1 FAILED. + // Bucket 2 (02:00–03:00): 1 CANCELED, 1 RUNNING (EXECUTING), 1 unknown-status + // (folds into RUNNING) => RUNNING total = 2. + // Plus a deleted row in bucket 0 that MUST be excluded. + 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"]); + + // 6 one-hour buckets, every bucket carries all four group keys. + expect(activity.data).toHaveLength(6); + 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 hour. + const expectedStart = Math.floor(from.getTime() / (60 * 60 * 1000)) * (60 * 60 * 1000); + const byBucket = new Map(activity.data.map((p) => [p.bucket, p])); + const p0 = byBucket.get(expectedStart)!; + const p2 = byBucket.get(expectedStart + 2 * 60 * 60 * 1000)!; + expect(p0).toBeDefined(); + expect(p2).toBeDefined(); + + // Bucket 0: 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); + + // Bucket 2: 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 00000000000..3ff15f103d1 --- /dev/null +++ b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts @@ -0,0 +1,647 @@ +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, + }; +} + +const neverCalledMigrated = async (id: string): Promise => { + throw new Error(`isKnownMigrated must not be invoked (called with ${id})`); +}; + +/** 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)", () => { + // --- DoD line + 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, + isKnownMigrated: async () => false, + }, + 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(); + } + } + ); + + // --- Known-migrated filter avoids re-probing the legacy replica --- + replicationContainerTest( + "a known-migrated id missing from the new probe is NOT re-probed against 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 } } }); + + try { + const ctx = await seedParents(prisma, "migfilter", "STANDARD"); + await mirrorParents(prismaNew, ctx, "migfilter"); + + // Seeded on the LEGACY/source DB (so CH has the id) but withheld from NEW, + // simulating replication lag where the new probe misses a freshly-migrated row. + const migrated = await createRun(prisma, ctx, { + friendlyId: "run_migrated", + payload: JSON.stringify({ kind: "migrated" }), + }); + + await setTimeout(1500); + + const presenter = new TestTaskPresenter( + prisma, + clickhouse, + { + splitEnabled: true, + newClient: prismaNew, + legacyReplica: throwingLegacyReplica(prisma), + isKnownMigrated: async (id) => id === migrated.id, + }, + new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }) + ); + + const result = await presenter.call({ + userId: "user_1", + projectId: ctx.projectId, + environment: envFor(ctx), + taskIdentifier: "my-task", + }); + + // Not on NEW, known-migrated -> never probed on legacy => absent (no throw). + if (!result.foundTask || result.triggerSource !== "STANDARD") { + throw new Error("expected a STANDARD task"); + } + expect(result.runs).toHaveLength(0); + } 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, + isKnownMigrated: async () => false, + }, + 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, legacy + isKnownMigrated never touched --- + replicationContainerTest( + "single-DB passthrough hydrates from one store read and never touches the legacy/known-migrated boundaries", + 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 throwing boundaries 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), + isKnownMigrated: neverCalledMigrated, + }, + 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, + isKnownMigrated: async () => false, + }, + 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(); + } + } + ); + + // --- e2e #6 (post-migration straggler): migrated row hydrates from NEW, legacy never touched --- + replicationContainerTest( + "a migrated straggler hydrates from the NEW DB and the legacy replica is never touched for it", + 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, "straggler", "STANDARD"); + await mirrorParents(prismaNew, ctx, "straggler"); + + // The id must reach ClickHouse, so it is seeded on the replication source (PG14) + // for the id-set, AND on NEW (PG17) where it is authoritative post-migration. + const straggler = await createRun(prisma, ctx, { + friendlyId: "run_straggler", + payload: JSON.stringify({ kind: "straggler" }), + }); + await copyRunWithId(prismaNew, ctx, { + id: straggler.id, + friendlyId: straggler.friendlyId, + payload: straggler.payload, + payloadType: JSON_TYPE, + createdAt: straggler.createdAt, + }); + + await setTimeout(1500); + + const presenter = new TestTaskPresenter( + prisma, + clickhouse, + { + splitEnabled: true, + newClient: prismaNew, + // Throws if the legacy replica is hydrated from — it must not be, the row is on NEW. + legacyReplica: throwingLegacyReplica(prisma), + isKnownMigrated: async () => true, + }, + 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([straggler.id]); + } 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 00000000000..ca15b00ea08 --- /dev/null +++ b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts @@ -0,0 +1,384 @@ +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 00000000000..43de91f3755 --- /dev/null +++ b/apps/webapp/test/runPresenterReadRoute.test.ts @@ -0,0 +1,282 @@ +// 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` is called at the start +// of each test to point the delegating Proxy at the real testcontainer client. +const { delegating, setCurrentPrisma } = vi.hoisted(() => { + let current: any = undefined; + const proxy = new Proxy( + {}, + { + get(_t, prop) { + if (!current) { + throw new Error("currentPrisma not set"); + } + 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/spanPresenterReadthroughDecompose.test.ts b/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts new file mode 100644 index 00000000000..3144860f228 --- /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 00000000000..b1211a2c090 --- /dev/null +++ b/apps/webapp/test/waitpointListPresenter.readroute.test.ts @@ -0,0 +1,474 @@ +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, PrismaClient, type WaitpointStatus } from "@trigger.dev/database"; +import { 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", () => { + // Task 3 Step 5 + Task 3 Step 7: 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); + }); + + // Task 3 Step 1: 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", + ]); + } + } + ); + + // Task 3 Step 2 + Step 4: 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); + } + ); + + // Task 3 Step 3: 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); + } + }); + + // RED before fix: $queryRaw across clients → P2010/42601 "$1". GREEN after: typed findMany. + 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 00000000000..f3513252eda --- /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 00000000000..8a1b924a7e5 --- /dev/null +++ b/apps/webapp/test/waitpointPresenter.readthrough.test.ts @@ -0,0 +1,555 @@ +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 { ensureRedirectMarkerTable } from "@internal/run-engine"; +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)", () => { + // --- Task 4 Step 1 / DoD part A: no false 404. Waitpoint lives ONLY on the legacy (PG14) + // replica, none on the new (PG17). split on. The lookup misses NEW, falls through to the legacy + // replica and resolves the detail row — the detail page must NOT 404 while the waitpoint drains + // on legacy. --- + heteroPostgresTest( + "Step 1: waitpoint only on the legacy replica still resolves (no false 404)", + async ({ prisma14, prisma17 }) => { + // isKnownMigrated reads the redirect-marker table on the legacy client and probes new. + await ensureRedirectMarkerTable(prisma14); + + const ctx = await seedParents(prisma14, "legacyonly"); + const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_legacyonly", { + tags: ["x", "y", "z"], + }); + + legacyReplicaHolder.client = prisma14; + newClientHolder.client = prisma17; + + const newClient = recording(prisma17); + const legacy = recording(prisma14); + + 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).not.toBeNull(); + expect(result?.id).toBe(seeded.friendlyId); + expect(result?.tags).toEqual(["x", "y", "z"]); + // NEW probed first (miss) -> resolved off the LEGACY REPLICA handle. + expect(newClient.calls.length).toBe(1); + expect(legacy.calls.length).toBe(1); + } + ); + + // --- Task 4 Step 2 (read half) / Step 3: 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( + "Step 3: 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); + } + ); + + // --- Task 4 Step 4: genuine 404. Nothing on either DB. split on. Both probes run, both miss -> + // null (the true 404 + logger.error path is preserved). --- + heteroPostgresTest( + "Step 4: a waitpoint absent from both DBs returns null (genuine 404 preserved)", + async ({ prisma14, prisma17 }) => { + // isKnownMigrated reads the redirect-marker table via runOpsLegacyReplica and probes new. + await ensureRedirectMarkerTable(prisma14); + legacyReplicaHolder.client = prisma14; + newClientHolder.client = prisma17; + + const ctx = await seedParents(prisma14, "missing"); + await mirrorParents(prisma17, ctx, "missing"); + + const newClient = recording(prisma17); + const legacy = recording(prisma14); + + const presenter = new WaitpointPresenter(undefined, undefined, { + splitEnabled: true, + newClient: newClient.handle, + legacyReplica: legacy.handle, + }); + + const result = await presenter.call(callArgs(ctx, "waitpoint_does_not_exist")); + + expect(result).toBeNull(); + // Both DBs were probed (NEW then legacy) before concluding the miss. + expect(newClient.calls.length).toBe(1); + expect(legacy.calls.length).toBe(1); + } + ); + + // --- Task 4 Step 5: old in-retention waitpoint served from the replica handle only. The deps + // expose `legacyReplica` and NO legacy-writer field, so the no-primary-read guarantee is + // structural. --- + heteroPostgresTest( + "Step 5: in-retention waitpoint served from the legacy replica (no legacy-writer field exists)", + async ({ prisma14, prisma17 }) => { + // isKnownMigrated reads the redirect-marker table on the legacy client and probes new. + await ensureRedirectMarkerTable(prisma14); + + const ctx = await seedParents(prisma14, "retention"); + const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_retention"); + + legacyReplicaHolder.client = prisma14; + newClientHolder.client = prisma17; + + const presenter = new WaitpointPresenter(undefined, undefined, { + splitEnabled: true, + newClient: recording(prisma17).handle, + // Only a replica handle is in scope; there is no legacyWriter dep to hit the primary. + legacyReplica: recording(prisma14).handle, + }); + + const result = await presenter.call(callArgs(ctx, seeded.friendlyId)); + + expect(result?.id).toBe(seeded.friendlyId); + } + ); + + // --- Task 4 Step 6 (read half): 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( + "Step 6: 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)", () => { + // --- Task 4 Step 2 / DoD part B: 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( + "Step 2: 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(); + } + } + ); + + // --- Task 4 Step 7 (e2e #2 read surface): a RUN/MANUAL resume-token waitpoint + its connected + // run co-resident on the LEGACY replica (the connectedRuns join is run-ops<->run-ops, so they + // migrate/abandon together). split on. The waitpoint misses NEW, resolves off legacy, and its + // connected run surfaces from the legacy replication source via CH — the suspended-run detail + // surface renders across the seam. --- + replicationContainerTest( + "Step 7 (e2e #2): resume-token waitpoint + connected run resolve via 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 PrismaClientCtor({ datasources: { db: { url: newUrl } } }); + legacyReplicaHolder.client = prisma; + newClientHolder.client = prismaNew; + clickhouseHolder.client = clickhouse; + + try { + // The connected run misses NEW, so the hydrate consults the known-migrated marker on the + // legacy replica before reading it back — the empty marker table must exist for that read. + await ensureRedirectMarkerTable(prisma); + + const ctx = await seedParents(prisma, "x2legacy"); + await mirrorParents(prismaNew, ctx, "x2legacy"); + + // Waitpoint + its connected run live ONLY on the legacy replica (PG14 = CH source). + await createRun(prisma, ctx, { friendlyId: "run_suspended" }); + const seeded = await seedWaitpoint(prisma, ctx, "waitpoint_resumetoken", { + type: "MANUAL", + connectedRunFriendlyIds: ["run_suspended"], + }); + + 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)).toEqual(["run_suspended"]); + } 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 00000000000..050612552bd --- /dev/null +++ b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts @@ -0,0 +1,213 @@ +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 { PrismaClient } from "@trigger.dev/database"; +import { 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); + } + ); +}); From f2d8d0297b6dde4b66603d4910f0d2abbaf32311 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Wed, 1 Jul 2026 18:32:35 +0100 Subject: [PATCH 02/14] fix(run-ops split): green the run-ops read presenter tests - readRedirectMarker fails open on an unprovisioned marker table (undefined_table): the known-migrated read optimization must never break the run-list read path when the marker table is absent. - expose runOps new/legacy handles through the db.server test seam so the routed-store reads resolve to the real containers. - hoist the runStore ref so the vi.mock factory no longer hits a TDZ. - correct the getActivity bucket expectation: a 6h window buckets into 72 five-minute buckets (chooseBucketSeconds targets ~72), not 6. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/SpanPresenter.readthrough.test.ts | 2 +- apps/webapp/test/apiRunListPresenter.test.ts | 6 ++++- .../nextRunListPresenter.readthrough.test.ts | 6 ++++- .../TaskDetailPresenter.getActivity.test.ts | 24 +++++++++---------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/webapp/test/SpanPresenter.readthrough.test.ts b/apps/webapp/test/SpanPresenter.readthrough.test.ts index 31aabcc2c00..96e332f9924 100644 --- a/apps/webapp/test/SpanPresenter.readthrough.test.ts +++ b/apps/webapp/test/SpanPresenter.readthrough.test.ts @@ -17,7 +17,7 @@ vi.mock("~/db.server", () => ({ $replica: {}, })); -const routingStoreRef: { current: unknown } = { current: undefined }; +const routingStoreRef = vi.hoisted(() => ({ current: undefined as unknown })); vi.mock("~/v3/runStore.server", () => ({ get runStore() { return routingStoreRef.current; diff --git a/apps/webapp/test/apiRunListPresenter.test.ts b/apps/webapp/test/apiRunListPresenter.test.ts index 435855d0b33..50d68b9562c 100644 --- a/apps/webapp/test/apiRunListPresenter.test.ts +++ b/apps/webapp/test/apiRunListPresenter.test.ts @@ -40,10 +40,14 @@ vi.mock("~/db.server", async () => { } ); const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client"); + const newProxy = lazyProxy(newClientHolder, "newClientHolder.client"); return { prisma: replicaProxy, $replica: replicaProxy, - runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"), + runOpsNewPrisma: newProxy, + runOpsNewReplica: newProxy, + runOpsLegacyPrisma: replicaProxy, + runOpsLegacyReplica: replicaProxy, sqlDatabaseSchema: Prisma.sql([`public`]), }; }); diff --git a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts index 0622a0fb8ea..211f1e6707e 100644 --- a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -30,10 +30,14 @@ vi.mock("~/db.server", async () => { } ); const replicaProxy = lazyProxy(legacyReplicaHolder, "legacyReplicaHolder.client"); + const newProxy = lazyProxy(newClientHolder, "newClientHolder.client"); return { prisma: replicaProxy, $replica: replicaProxy, - runOpsNewPrisma: lazyProxy(newClientHolder, "newClientHolder.client"), + runOpsNewPrisma: newProxy, + runOpsNewReplica: newProxy, + runOpsLegacyPrisma: replicaProxy, + runOpsLegacyReplica: replicaProxy, sqlDatabaseSchema: Prisma.sql([`public`]), }; }); diff --git a/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts b/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts index 013d8615e3f..d4c92b7c418 100644 --- a/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts +++ b/apps/webapp/test/presenters/TaskDetailPresenter.getActivity.test.ts @@ -69,14 +69,13 @@ describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => { settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 }, }); - // 6h window => 1h buckets => 6 buckets. + // 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; - // Bucket 0 (00:00–01:00): 1 COMPLETED, 1 FAILED. - // Bucket 2 (02:00–03:00): 1 CANCELED, 1 RUNNING (EXECUTING), 1 unknown-status - // (folds into RUNNING) => RUNNING total = 2. - // Plus a deleted row in bucket 0 that MUST be excluded. + // 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(); @@ -116,8 +115,8 @@ describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => { // Stable legend, fixed group order. expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]); - // 6 one-hour buckets, every bucket carries all four group keys. - expect(activity.data).toHaveLength(6); + // 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"); @@ -126,21 +125,20 @@ describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => { expect(point).toHaveProperty("RUNNING"); } - // Buckets are epoch MILLISECONDS aligned to the hour. - const expectedStart = Math.floor(from.getTime() / (60 * 60 * 1000)) * (60 * 60 * 1000); + // 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(expectedStart)!; - const p2 = byBucket.get(expectedStart + 2 * 60 * 60 * 1000)!; + 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(); - // Bucket 0: 1 COMPLETED, 1 FAILED, deleted row excluded. + // 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); - // Bucket 2: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2. + // 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); From 7181b2f37f97070bed0a345e7dc5432af7afda2b Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 12:27:21 +0100 Subject: [PATCH 03/14] refactor(run-ops): drop known-migrated from read presenters; id-shape only Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/ApiBatchResultsPresenter.server.ts | 3 - .../v3/ApiRunResultPresenter.server.ts | 4 +- .../v3/ApiWaitpointPresenter.server.ts | 9 +- .../presenters/v3/TestTaskPresenter.server.ts | 15 +- .../v3/WaitpointPresenter.server.ts | 3 - ...iBatchResultsPresenter.readthrough.test.ts | 76 +-------- .../apiRunResultPresenter.readthrough.test.ts | 49 +----- .../apiWaitpointPresenter.readthrough.test.ts | 53 +----- apps/webapp/test/batchPresenter.test.ts | 23 +-- .../nextRunListPresenter.readthrough.test.ts | 4 +- .../TestTaskPresenter.readthrough.test.ts | 138 +--------------- .../waitpointPresenter.readthrough.test.ts | 153 ------------------ 12 files changed, 29 insertions(+), 501 deletions(-) diff --git a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts index 73120185236..6d46074e726 100644 --- a/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts @@ -8,7 +8,6 @@ import { import type { TaskRunWithAttempts } from "~/models/taskRun.server"; import { executionResultForTaskRun } from "~/models/taskRun.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { isKnownMigrated as defaultIsKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server"; import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server"; import { runStore as defaultRunStore } from "~/v3/runStore.server"; import { BasePresenter } from "./basePresenter.server"; @@ -21,7 +20,6 @@ type ApiBatchResultsReadThroughDeps = { splitEnabled?: boolean; newClient?: PrismaReplicaClient; legacyReplica?: PrismaReplicaClient; - isKnownMigrated?: (runId: string) => Promise; isPastRetention?: (runId: string) => boolean; }; @@ -199,7 +197,6 @@ export class ApiBatchResultsPresenter extends BasePresenter { // own module-level defaults would diverge from the batch read's `?? this._replica`.) newClient, legacyReplica, - isKnownMigrated: this.readThrough?.isKnownMigrated ?? defaultIsKnownMigrated, isPastRetention: this.readThrough?.isPastRetention, }, }); diff --git a/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts index 320b0eba004..1fef986600c 100644 --- a/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunResultPresenter.server.ts @@ -10,7 +10,6 @@ type ApiRunResultReadThroughDeps = { newClient?: PrismaReplicaClient; // LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to this._replica. legacyReplica?: PrismaReplicaClient; - isKnownMigrated?: (runId: string) => Promise; isPastRetention?: (runId: string) => boolean; }; @@ -35,7 +34,7 @@ export class ApiRunResultPresenter extends BasePresenter { }); // Single-run result poll routed through run-ops read-through. Split on: primary store first, - // then the secondary read replica only for runs not known-migrated; past-retention ids return + // 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({ @@ -47,7 +46,6 @@ export class ApiRunResultPresenter extends BasePresenter { splitEnabled: this._readThrough?.splitEnabled, newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient), legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient), - isKnownMigrated: this._readThrough?.isKnownMigrated, isPastRetention: this._readThrough?.isPastRetention, }, }); diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts index 8f011a03545..9cb541c1dd0 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -6,16 +6,14 @@ 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"; -import { isKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server"; // When omitted, clients default to the inherited _replica handle => passthrough reads the -// replica exactly as today. Pure boundaries (isKnownMigrated/isPastRetention) are injectable -// for tests. Typed PrismaReplicaClient to match readThroughRun's readNew/readLegacy + deps. +// 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; - isKnownMigrated?: (id: string) => Promise; isPastRetention?: (id: string) => boolean; }; @@ -42,7 +40,7 @@ export class ApiWaitpointPresenter extends BasePresenter { ) { return this.trace("call", async (span) => { // Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY - // RUN-OPS READ REPLICA ONLY for ids not known-migrated — never the legacy primary. + // 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). @@ -90,7 +88,6 @@ export class ApiWaitpointPresenter extends BasePresenter { newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient), legacyReplica: this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient), - isKnownMigrated: this.readThroughDeps?.isKnownMigrated ?? isKnownMigrated, isPastRetention: this.readThroughDeps?.isPastRetention, }, }); diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index d8bc1ade64a..430477ce582 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -14,7 +14,6 @@ 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 { isKnownMigrated as defaultIsKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server"; import { runStore as defaultRunStore } from "~/v3/runStore.server"; import { queueTypeFromType } from "./QueueRetrievePresenter.server"; @@ -26,7 +25,6 @@ type TestTaskReadThroughDeps = { legacyReplica?: PrismaClientOrTransaction; // Resolved boot constant; when false the split branch is never entered. splitEnabled?: boolean; - isKnownMigrated?: (runId: string) => Promise; }; // The byte-identical select the recent-payloads hydrate has always used; `id` is @@ -414,7 +412,7 @@ export class TestTaskPresenter { } // 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 not known-migrated — + // 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) { @@ -427,18 +425,11 @@ export class TestTaskPresenter { const newClient = this.readThrough.newClient ?? this.replica; const legacyReplica = this.readThrough.legacyReplica ?? this.replica; - const isKnownMigrated = this.readThrough.isKnownMigrated ?? defaultIsKnownMigrated; const newRows = await this.hydrateOnClient(newClient, runIds); const foundIds = new Set(newRows.map((r) => r.id)); - const missing = runIds.filter((id) => !foundIds.has(id)); - - const toProbeLegacy: string[] = []; - for (const id of missing) { - if (!(await isKnownMigrated(id))) { - toProbeLegacy.push(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) diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index f40dcfe6eef..e582705b75a 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -9,7 +9,6 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; import { logger } from "~/services/logger.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; -import { isKnownMigrated } from "~/v3/runOpsMigration/knownMigratedFilter.server"; import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server"; import { BasePresenter } from "./basePresenter.server"; import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server"; @@ -29,7 +28,6 @@ export class WaitpointPresenter extends BasePresenter { // Resolved boot constant from isSplitEnabled(). When false/absent: // the waitpoint lookup is one plain findFirst and the connected-runs hydrate runs passthrough. splitEnabled?: boolean; - isKnownMigrated?: (id: string) => Promise; } ) { super(prisma, replica); @@ -80,7 +78,6 @@ export class WaitpointPresenter extends BasePresenter { legacyReplica: (this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ?? runOpsLegacyReplica, - isKnownMigrated: this.readThroughDeps.isKnownMigrated ?? isKnownMigrated, }, }); diff --git a/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts index 9349525db6b..4b0957767ac 100644 --- a/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiBatchResultsPresenter.readthrough.test.ts @@ -4,7 +4,7 @@ // 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 / isKnownMigrated / isPastRetention). +// 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 @@ -196,8 +196,6 @@ const env = (ctx: SeedCtx) => project: { name: ctx.project.name }, }) as unknown as AuthenticatedEnvironment; -const neverMigrated = async () => false; - describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { // A batch with members on BOTH DBs returns the complete set, byte-identical. heteroPostgresTest( @@ -235,7 +233,6 @@ describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: neverMigrated, }); const result = await presenter.call("batch_span", env(ctx)); @@ -286,7 +283,6 @@ describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: neverMigrated, }); const result = await presenter.call("batch_legacy", env(ctx)); @@ -298,69 +294,6 @@ describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { } ); - // A known-migrated member is not re-probed against the legacy replica. - heteroPostgresTest( - "a known-migrated member missing from the new probe is NOT re-probed on legacy", - async ({ prisma14, prisma17 }) => { - const newDb = prisma17 as unknown as PrismaClient; - const legacyDb = prisma14 as unknown as PrismaClient; - - const ctx = await seedEnv(newDb, "mig-new"); - await mirrorEnv(legacyDb, ctx, "mig-legacy"); - await relaxFks(newDb); - await relaxFks(legacyDb); - - // A legacy-classified id (so the LEGACY fan-out branch is reachable) that is "known migrated". - const migratedId = legacyRunId("d"); - // Seed the member ONLY on legacy, but mark it known-migrated → legacy must NOT be probed, - // so it resolves to not-found and is omitted. - await seedMember(legacyDb, ctx, { - id: migratedId, - friendlyId: "run_migrated_d", - status: "COMPLETED_SUCCESSFULLY", - output: JSON.stringify({}), - }); - await seedBatch(newDb, ctx, "batch_mig", [migratedId]); - - // Spy legacy replica: throw if the member-hydrate findFirst runs for the migrated id. - const legacySpy = new Proxy(prisma14, { - get(target, prop) { - if (prop === "taskRun") { - return new Proxy((target as any).taskRun, { - get(trTarget, trProp) { - if (trProp === "findFirst") { - return async (args: any) => { - if (args?.where?.id === migratedId) { - throw new Error( - "legacy replica must not be probed for a known-migrated member" - ); - } - return (trTarget as any).findFirst(args); - }; - } - return (trTarget as any)[trProp]; - }, - }); - } - return (target as any)[prop]; - }, - }) as unknown as PrismaReplicaClient; - - const presenter = new ApiBatchResultsPresenter(throwingPrisma, throwingPrisma, { - splitEnabled: true, - newClient: prisma17 as unknown as PrismaReplicaClient, - legacyReplica: legacySpy, - isKnownMigrated: async (id) => id === migratedId, - }); - - const result = await presenter.call("batch_mig", env(ctx)); - - expect(result).toBeDefined(); - // Known-migrated + missing from NEW → not-found → omitted, without probing legacy. - expect(result!.items).toHaveLength(0); - } - ); - // 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", @@ -388,7 +321,6 @@ describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: neverMigrated, }); const result = await presenter.call("batch_dangle", env(ctx)); @@ -411,7 +343,6 @@ describe("ApiBatchResultsPresenter read-through (legacy + new DB)", () => { splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: neverMigrated, }); const result = await presenter.call("batch_does_not_exist", env(ctx)); @@ -439,16 +370,13 @@ describe("ApiBatchResultsPresenter passthrough (single-DB collapse)", () => { const runStore = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); - // Throwing boundaries: if the split path is entered, these blow up. + // Throwing legacy replica: if the split path is entered, it blows up. const presenter = new ApiBatchResultsPresenter( prisma, prisma, { splitEnabled: false, legacyReplica: throwingPrisma, - isKnownMigrated: async () => { - throw new Error("isKnownMigrated must not be invoked on the passthrough path"); - }, }, runStore ); diff --git a/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts index ff0b141bd0c..13357ea130a 100644 --- a/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts @@ -1,9 +1,9 @@ // 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 only for not-known-migrated runs (never a primary), +// 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/isKnownMigrated/isPastRetention) are injected. +// boundaries (splitEnabled/isPastRetention) are injected. import { heteroPostgresTest } from "@internal/testcontainers"; import type { PrismaClient } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; @@ -250,7 +250,6 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: async () => false, } ); @@ -267,38 +266,6 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre } ); - // A legacy-classified id that lives on the new DB: the new read hits it, so the known-migrated - // short-circuit means the legacy replica is never probed. - heteroPostgresTest( - "split: a known-migrated run is served from new and the legacy replica is never probed for it", - async ({ prisma14, prisma17 }) => { - const friendlyId = legacyFriendlyId(); - const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "migrated"); - await seedRunWithAttempt(prisma17 as unknown as PrismaClient, ctx, friendlyId, { - status: "COMPLETED_SUCCESSFULLY", - attempt: { status: "COMPLETED", output: '"on-new"', 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(), - isKnownMigrated: async () => true, - } - ); - - 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('"on-new"'); - } - } - ); - // 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)", @@ -314,7 +281,6 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: prisma14 as unknown as PrismaReplicaClient, - isKnownMigrated: async () => false, isPastRetention: () => true, } ); @@ -326,7 +292,7 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre ); heteroPostgresTest( - "single-DB passthrough: resolves from the one client; legacy + isKnownMigrated are never invoked", + "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"); @@ -335,10 +301,6 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre attempt: { status: "COMPLETED", output: '"single"', outputType: "application/json" }, }); - const throwingFilter = vi.fn(async () => { - throw new Error("isKnownMigrated must never run in single-DB mode"); - }); - // No read-through deps → passthrough (single plain findFirst). const presenter = new ApiRunResultPresenter( prisma17 as unknown as PrismaReplicaClient, @@ -351,9 +313,8 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre expect(result.id).toBe(friendlyId); expect(result.output).toBe('"single"'); } - expect(throwingFilter).not.toHaveBeenCalled(); - // splitEnabled:false with throwing legacy + filter proves no second store is touched. + // 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, @@ -361,12 +322,10 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: false, newClient: prisma17 as unknown as PrismaReplicaClient, legacyReplica: throwingLegacy(), - isKnownMigrated: throwingFilter, } ); const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId)); expect(result2?.ok).toBe(true); - expect(throwingFilter).not.toHaveBeenCalled(); } ); diff --git a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts index 5087c61d473..a59f84473b0 100644 --- a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -1,6 +1,6 @@ // 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, isKnownMigrated, isPastRetention) and recording client wrappers are +// (splitEnabled, isPastRetention) and recording client wrappers are // injected. heteroPostgresTest runs the legacy and new databases on different major versions. import { heteroPostgresTest, postgresTest } from "@internal/testcontainers"; import type { PrismaClient, WaitpointType } from "@trigger.dev/database"; @@ -126,7 +126,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: newClient.handle, legacyReplica: legacy.handle, - isKnownMigrated: async () => false, }); const result = await presenter.call(environmentArg(environment), id); @@ -161,7 +160,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: newClient.handle, legacyReplica: legacy.handle, - isKnownMigrated: async () => false, }); const result = await presenter.call(environmentArg(environment), id); @@ -174,35 +172,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre } ); - heteroPostgresTest( - "a known-migrated waitpoint is not re-probed against legacy", - async ({ prisma17, prisma14 }) => { - // Legacy-residency id (forces the new-first-then-legacy fan-out), but the new probe - // misses (simulated lag: empty NEW DB) and isKnownMigrated returns true → no legacy probe. - const id = generateLegacyCuid(); - - const { project, environment } = await seedOrgProjectEnv(prisma14, "migrated"); - - const newClient = recording(prisma17); - const legacy = recording(prisma14, { forbidden: true }); - - const presenter = new ApiWaitpointPresenter(undefined, undefined, { - splitEnabled: true, - newClient: newClient.handle, - legacyReplica: legacy.handle, - isKnownMigrated: async () => true, - }); - - await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow( - "Waitpoint not found" - ); - - expect(newClient.calls.length).toBe(1); - // known-migrated short-circuit: legacy never probed. - expect(legacy.calls.length).toBe(0); - } - ); - heteroPostgresTest( "not-found maps to the existing ServiceValidationError surface", async ({ prisma17, prisma14 }) => { @@ -213,7 +182,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: recording(prisma17).handle, legacyReplica: recording(prisma14).handle, - isKnownMigrated: async () => false, }); await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow( @@ -232,7 +200,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: recording(prisma17).handle, legacyReplica: recording(prisma14).handle, - isKnownMigrated: async () => false, isPastRetention: () => true, }); @@ -243,9 +210,9 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre ); heteroPostgresTest( - "cross-seam — migrated served from NEW (legacy untouched); in-retention served from legacy", + "cross-seam — new-resident served from NEW (legacy untouched); in-retention served from legacy", async ({ prisma17, prisma14 }) => { - // Migrated waitpoint: lives on NEW, isKnownMigrated true, legacy must never be touched. + // 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, { @@ -257,7 +224,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: recording(prisma17).handle, legacyReplica: newLegacy.handle, - isKnownMigrated: async () => true, }); const migratedResult = await migratedPresenter.call(environmentArg(newEnv.environment), newId); expect(migratedResult.id).toBe(`waitpoint_${newId}`); @@ -274,7 +240,6 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre splitEnabled: true, newClient: recording(prisma17).handle, legacyReplica: recording(prisma14).handle, - isKnownMigrated: async () => false, }); const retentionResult = await retentionPresenter.call(environmentArg(oldEnv.environment), oldId); expect(retentionResult.id).toBe(`waitpoint_${oldId}`); @@ -284,7 +249,7 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre describe("ApiWaitpointPresenter passthrough (single-DB)", () => { postgresTest( - "no read-through deps → one plain replica read; legacy + isKnownMigrated never invoked", + "no read-through deps → one plain replica read; legacy never touched", async ({ prisma }) => { const id = generateKsuidId(); const { project, environment } = await seedOrgProjectEnv(prisma, "pt"); @@ -296,18 +261,13 @@ describe("ApiWaitpointPresenter passthrough (single-DB)", () => { ); const single = recording(prisma); - let knownMigratedInvoked = false; 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 + isKnownMigrated must never fire. + // can assert exactly one read against it; legacy must never fire. const presenter = new ApiWaitpointPresenter(undefined, undefined, { newClient: single.handle, legacyReplica: legacy.handle, - isKnownMigrated: async () => { - knownMigratedInvoked = true; - return false; - }, }); const result = await presenter.call(environmentArg(environment), id); @@ -315,10 +275,9 @@ describe("ApiWaitpointPresenter passthrough (single-DB)", () => { 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 + known-migrated untouched. + // Passthrough: exactly one read on the single client; legacy untouched. expect(single.calls.length).toBe(1); expect(legacy.calls.length).toBe(0); - expect(knownMigratedInvoked).toBe(false); } ); }); diff --git a/apps/webapp/test/batchPresenter.test.ts b/apps/webapp/test/batchPresenter.test.ts index fada97a1b51..3173dbe46a3 100644 --- a/apps/webapp/test/batchPresenter.test.ts +++ b/apps/webapp/test/batchPresenter.test.ts @@ -136,19 +136,6 @@ function makeEnvResolver(controlPlane: PrismaClient) { }; } -/** - * Wraps the REAL readThroughRun but forces isKnownMigrated false so the layer is hermetic - * (the default isKnownMigrated would import ~/db.server and probe an ambient .env DB the test - * never seeded). Pure boundary injection — the DB reads still hit the real containers. - */ -function hermeticReadThrough(isKnownMigrated: (id: string) => Promise = async () => false) { - return (input: Parameters>[0]) => - readThroughRun({ - ...input, - deps: { ...input.deps, isKnownMigrated }, - }); -} - describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { // DoD: batch detail resolves on run-ops NEW (split on). Legacy replica is never probed. heteroPostgresTest( @@ -176,7 +163,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { splitEnabled: true, newClient: prisma17, legacyReplica: tripwireLegacy, - readThrough: hermeticReadThrough(), + readThrough: readThroughRun, resolveDisplayableEnvironment: makeEnvResolver(prisma17), }); @@ -232,8 +219,8 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { splitEnabled: true, newClient: prisma17, // NEW probe misses (nothing seeded there) legacyReplica: prisma14, - // Real readThroughRun; isKnownMigrated forced false (no marker) so it falls to legacy. - readThrough: hermeticReadThrough(), + // Real readThroughRun; the NEW miss falls through to the legacy replica. + readThrough: readThroughRun, resolveDisplayableEnvironment: makeEnvResolver(prisma14), }); @@ -262,7 +249,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { splitEnabled: true, newClient: prisma17, legacyReplica: prisma14, - readThrough: hermeticReadThrough(), + readThrough: readThroughRun, resolveDisplayableEnvironment: makeEnvResolver(prisma14), }); @@ -283,7 +270,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { splitEnabled: true, newClient: prisma17, legacyReplica: prisma14, - readThrough: hermeticReadThrough(), + readThrough: readThroughRun, resolveDisplayableEnvironment: makeEnvResolver(prisma17), }); diff --git a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts index 211f1e6707e..ba76ab4bec2 100644 --- a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -335,8 +335,8 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac // --- 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. - // The presenter cannot supply isKnownMigrated, so the routed hydrate runs without it; we assert - // the rows that DO surface (the full union, since legacy is probed for legacy-only ids). + // 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 diff --git a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts index 3ff15f103d1..d76e8dd422c 100644 --- a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts +++ b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts @@ -180,10 +180,6 @@ function envFor(ctx: SeedContext) { }; } -const neverCalledMigrated = async (id: string): Promise => { - throw new Error(`isKnownMigrated must not be invoked (called with ${id})`); -}; - /** A legacy-replica handle whose taskRun.findMany throws — proves it is never hydrated from. */ function throwingLegacyReplica(prisma: PrismaClient): PrismaClient { return new Proxy(prisma, { @@ -279,7 +275,6 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new splitEnabled: true, newClient: prismaNew, legacyReplica: prisma, - isKnownMigrated: async () => false, }, new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }) ); @@ -317,65 +312,6 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- Known-migrated filter avoids re-probing the legacy replica --- - replicationContainerTest( - "a known-migrated id missing from the new probe is NOT re-probed against 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 } } }); - - try { - const ctx = await seedParents(prisma, "migfilter", "STANDARD"); - await mirrorParents(prismaNew, ctx, "migfilter"); - - // Seeded on the LEGACY/source DB (so CH has the id) but withheld from NEW, - // simulating replication lag where the new probe misses a freshly-migrated row. - const migrated = await createRun(prisma, ctx, { - friendlyId: "run_migrated", - payload: JSON.stringify({ kind: "migrated" }), - }); - - await setTimeout(1500); - - const presenter = new TestTaskPresenter( - prisma, - clickhouse, - { - splitEnabled: true, - newClient: prismaNew, - legacyReplica: throwingLegacyReplica(prisma), - isKnownMigrated: async (id) => id === migrated.id, - }, - new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }) - ); - - const result = await presenter.call({ - userId: "user_1", - projectId: ctx.projectId, - environment: envFor(ctx), - taskIdentifier: "my-task", - }); - - // Not on NEW, known-migrated -> never probed on legacy => absent (no throw). - if (!result.foundTask || result.triggerSource !== "STANDARD") { - throw new Error("expected a STANDARD task"); - } - expect(result.runs).toHaveLength(0); - } 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", @@ -412,7 +348,6 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new splitEnabled: true, newClient: prismaNew, legacyReplica: prisma, - isKnownMigrated: async () => false, }, new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }) ); @@ -434,9 +369,9 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- Passthrough (single-DB): one plain store read, legacy + isKnownMigrated never touched --- + // --- 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/known-migrated boundaries", + "single-DB passthrough hydrates from one store read and never touches the legacy replica", async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => { const { clickhouse } = await setupClickhouseReplication({ prisma, @@ -466,7 +401,7 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new await setTimeout(1500); - // No readThrough (split off). Inject throwing boundaries to prove the split branch + // 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, @@ -474,7 +409,6 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new { splitEnabled: false, legacyReplica: throwingLegacyReplica(prisma), - isKnownMigrated: neverCalledMigrated, }, new PostgresRunStore({ prisma, readOnlyPrisma: prisma }) ); @@ -554,7 +488,6 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new splitEnabled: true, newClient: prismaNew, legacyReplica: prisma, - isKnownMigrated: async () => false, }, new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }) ); @@ -579,69 +512,4 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- e2e #6 (post-migration straggler): migrated row hydrates from NEW, legacy never touched --- - replicationContainerTest( - "a migrated straggler hydrates from the NEW DB and the legacy replica is never touched for it", - 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, "straggler", "STANDARD"); - await mirrorParents(prismaNew, ctx, "straggler"); - - // The id must reach ClickHouse, so it is seeded on the replication source (PG14) - // for the id-set, AND on NEW (PG17) where it is authoritative post-migration. - const straggler = await createRun(prisma, ctx, { - friendlyId: "run_straggler", - payload: JSON.stringify({ kind: "straggler" }), - }); - await copyRunWithId(prismaNew, ctx, { - id: straggler.id, - friendlyId: straggler.friendlyId, - payload: straggler.payload, - payloadType: JSON_TYPE, - createdAt: straggler.createdAt, - }); - - await setTimeout(1500); - - const presenter = new TestTaskPresenter( - prisma, - clickhouse, - { - splitEnabled: true, - newClient: prismaNew, - // Throws if the legacy replica is hydrated from — it must not be, the row is on NEW. - legacyReplica: throwingLegacyReplica(prisma), - isKnownMigrated: async () => true, - }, - 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([straggler.id]); - } finally { - await prismaNew.$disconnect(); - } - } - ); }); diff --git a/apps/webapp/test/waitpointPresenter.readthrough.test.ts b/apps/webapp/test/waitpointPresenter.readthrough.test.ts index 8a1b924a7e5..33ee847d730 100644 --- a/apps/webapp/test/waitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/waitpointPresenter.readthrough.test.ts @@ -58,7 +58,6 @@ import { heteroPostgresTest, replicationContainerTest, } from "@internal/testcontainers"; -import { ensureRedirectMarkerTable } from "@internal/run-engine"; import type { PrismaClient, WaitpointType } from "@trigger.dev/database"; import { PrismaClient as PrismaClientCtor } from "@trigger.dev/database"; import { setTimeout } from "node:timers/promises"; @@ -230,44 +229,6 @@ const callArgs = (ctx: SeedContext, friendlyId: string) => ({ }); describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connected runs)", () => { - // --- Task 4 Step 1 / DoD part A: no false 404. Waitpoint lives ONLY on the legacy (PG14) - // replica, none on the new (PG17). split on. The lookup misses NEW, falls through to the legacy - // replica and resolves the detail row — the detail page must NOT 404 while the waitpoint drains - // on legacy. --- - heteroPostgresTest( - "Step 1: waitpoint only on the legacy replica still resolves (no false 404)", - async ({ prisma14, prisma17 }) => { - // isKnownMigrated reads the redirect-marker table on the legacy client and probes new. - await ensureRedirectMarkerTable(prisma14); - - const ctx = await seedParents(prisma14, "legacyonly"); - const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_legacyonly", { - tags: ["x", "y", "z"], - }); - - legacyReplicaHolder.client = prisma14; - newClientHolder.client = prisma17; - - const newClient = recording(prisma17); - const legacy = recording(prisma14); - - 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).not.toBeNull(); - expect(result?.id).toBe(seeded.friendlyId); - expect(result?.tags).toEqual(["x", "y", "z"]); - // NEW probed first (miss) -> resolved off the LEGACY REPLICA handle. - expect(newClient.calls.length).toBe(1); - expect(legacy.calls.length).toBe(1); - } - ); - // --- Task 4 Step 2 (read half) / Step 3: 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. --- @@ -299,65 +260,6 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec } ); - // --- Task 4 Step 4: genuine 404. Nothing on either DB. split on. Both probes run, both miss -> - // null (the true 404 + logger.error path is preserved). --- - heteroPostgresTest( - "Step 4: a waitpoint absent from both DBs returns null (genuine 404 preserved)", - async ({ prisma14, prisma17 }) => { - // isKnownMigrated reads the redirect-marker table via runOpsLegacyReplica and probes new. - await ensureRedirectMarkerTable(prisma14); - legacyReplicaHolder.client = prisma14; - newClientHolder.client = prisma17; - - const ctx = await seedParents(prisma14, "missing"); - await mirrorParents(prisma17, ctx, "missing"); - - const newClient = recording(prisma17); - const legacy = recording(prisma14); - - const presenter = new WaitpointPresenter(undefined, undefined, { - splitEnabled: true, - newClient: newClient.handle, - legacyReplica: legacy.handle, - }); - - const result = await presenter.call(callArgs(ctx, "waitpoint_does_not_exist")); - - expect(result).toBeNull(); - // Both DBs were probed (NEW then legacy) before concluding the miss. - expect(newClient.calls.length).toBe(1); - expect(legacy.calls.length).toBe(1); - } - ); - - // --- Task 4 Step 5: old in-retention waitpoint served from the replica handle only. The deps - // expose `legacyReplica` and NO legacy-writer field, so the no-primary-read guarantee is - // structural. --- - heteroPostgresTest( - "Step 5: in-retention waitpoint served from the legacy replica (no legacy-writer field exists)", - async ({ prisma14, prisma17 }) => { - // isKnownMigrated reads the redirect-marker table on the legacy client and probes new. - await ensureRedirectMarkerTable(prisma14); - - const ctx = await seedParents(prisma14, "retention"); - const seeded = await seedWaitpoint(prisma14, ctx, "waitpoint_retention"); - - legacyReplicaHolder.client = prisma14; - newClientHolder.client = prisma17; - - const presenter = new WaitpointPresenter(undefined, undefined, { - splitEnabled: true, - newClient: recording(prisma17).handle, - // Only a replica handle is in scope; there is no legacyWriter dep to hit the primary. - legacyReplica: recording(prisma14).handle, - }); - - const result = await presenter.call(callArgs(ctx, seeded.friendlyId)); - - expect(result?.id).toBe(seeded.friendlyId); - } - ); - // --- Task 4 Step 6 (read half): 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. --- @@ -473,61 +375,6 @@ describe("WaitpointPresenter connected-runs hydrate routed through read-through } ); - // --- Task 4 Step 7 (e2e #2 read surface): a RUN/MANUAL resume-token waitpoint + its connected - // run co-resident on the LEGACY replica (the connectedRuns join is run-ops<->run-ops, so they - // migrate/abandon together). split on. The waitpoint misses NEW, resolves off legacy, and its - // connected run surfaces from the legacy replication source via CH — the suspended-run detail - // surface renders across the seam. --- - replicationContainerTest( - "Step 7 (e2e #2): resume-token waitpoint + connected run resolve via 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 PrismaClientCtor({ datasources: { db: { url: newUrl } } }); - legacyReplicaHolder.client = prisma; - newClientHolder.client = prismaNew; - clickhouseHolder.client = clickhouse; - - try { - // The connected run misses NEW, so the hydrate consults the known-migrated marker on the - // legacy replica before reading it back — the empty marker table must exist for that read. - await ensureRedirectMarkerTable(prisma); - - const ctx = await seedParents(prisma, "x2legacy"); - await mirrorParents(prismaNew, ctx, "x2legacy"); - - // Waitpoint + its connected run live ONLY on the legacy replica (PG14 = CH source). - await createRun(prisma, ctx, { friendlyId: "run_suspended" }); - const seeded = await seedWaitpoint(prisma, ctx, "waitpoint_resumetoken", { - type: "MANUAL", - connectedRunFriendlyIds: ["run_suspended"], - }); - - 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)).toEqual(["run_suspended"]); - } finally { - await prismaNew.$disconnect(); - } - } - ); }); describe("WaitpointPresenter bare-ctor production default activates readThroughRun", () => { From ff73dac6eaf31fda0d9b41e9a0751173c09967c1 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 14:47:04 +0100 Subject: [PATCH 04/14] chore(run-ops split): strip review-scaffolding comments/labels from pr09 presenters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment/label hygiene pass over the PR09 presenter read-routing work. No product logic, assertions, seeds, or test structure changed — only comment text and test/it titles. - Remove the SPLIT-NEUTRAL scaffolding comment on TaskDetailPresenter.getActivity. - Drop `// --- ... ---` comment fencing across the touched test files, keeping the behavioral text. - Strip Task/Step enumeration prefixes from comments and it/test titles, keeping the behavioral descriptions. - Remove Definition-of-Done ("DoD") framing and RED/GREEN TDD phase commentary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/TaskDetailPresenter.server.ts | 2 -- .../test/SpanPresenter.readthrough.test.ts | 14 ++++++------- .../apiRetrieveRunPresenter.readroute.test.ts | 6 +++--- apps/webapp/test/apiRunListPresenter.test.ts | 8 ++++---- .../test/batchListPresenter.readroute.test.ts | 11 +++++----- apps/webapp/test/batchPresenter.test.ts | 10 +++++----- .../nextRunListPresenter.readthrough.test.ts | 20 +++++++++---------- .../TestTaskPresenter.readthrough.test.ts | 8 ++++---- .../clickHouseRunListResolver.test.ts | 12 +++++------ .../waitpointListPresenter.readroute.test.ts | 9 ++++----- .../waitpointPresenter.readthrough.test.ts | 18 ++++++++--------- 11 files changed, 57 insertions(+), 61 deletions(-) diff --git a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts index 53e690c1191..d4bd38cf643 100644 --- a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts @@ -141,8 +141,6 @@ export class TaskDetailPresenter { }; } - // SPLIT-NEUTRAL: served entirely from ClickHouse (task_runs_v2); - // no run-ops Postgres read — single-DB behavior is n-a, RoutingRunStore is not involved. async getActivity({ organizationId, projectId, diff --git a/apps/webapp/test/SpanPresenter.readthrough.test.ts b/apps/webapp/test/SpanPresenter.readthrough.test.ts index 96e332f9924..37ef0a661dd 100644 --- a/apps/webapp/test/SpanPresenter.readthrough.test.ts +++ b/apps/webapp/test/SpanPresenter.readthrough.test.ts @@ -208,8 +208,8 @@ function asReplica(prisma: PrismaClient): 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. --- + // 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 }) => { @@ -297,7 +297,7 @@ describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => { } ); - // --- Children set served by runStore.findRuns through the routing store --- + // 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 }) => { @@ -344,7 +344,7 @@ describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => { } ); - // --- Old in-retention run served from the legacy replica, never the primary --- + // 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 }) => { @@ -377,7 +377,7 @@ describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => { } ); - // --- A known-migrated run is not re-probed on legacy --- + // 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 }) => { @@ -417,7 +417,7 @@ describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => { } ); - // --- Passthrough (single-DB): NEW and LEGACY slots are the same store over one client --- + // 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 }) => { @@ -484,7 +484,7 @@ describe("SpanPresenter run-ops/control-plane partition (legacy + new)", () => { } ); - // --- Cross-seam tree shape: parent on LEGACY (in-retention), child on NEW (born-new) --- + // 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 }) => { diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts index 9522307d499..a5516687b51 100644 --- a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -227,9 +227,9 @@ async function seedAttempt( }); } -// Seed a run plus a parent, a root, a child and one attempt — the tree the DoD -// asserts round-trips. `seedTestRun.ts` only seeds a single root run, so the -// tree + attempt rows are created inline here (per the plan's seeding note). +// 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: { diff --git a/apps/webapp/test/apiRunListPresenter.test.ts b/apps/webapp/test/apiRunListPresenter.test.ts index 50d68b9562c..bc9826461b9 100644 --- a/apps/webapp/test/apiRunListPresenter.test.ts +++ b/apps/webapp/test/apiRunListPresenter.test.ts @@ -188,7 +188,7 @@ async function createRun( } describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => { - // Step 1 + Step 6 (e2e #6): public list serves run-ops rows through the routed store. The + // 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 @@ -281,7 +281,7 @@ describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => } ); - // Step 2: genuinely-empty env returns { data: [], pagination } without error. Exercises the + // 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", @@ -326,7 +326,7 @@ describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => } ); - // Step 3 / DoD "env scoping unchanged": the control-plane runtimeEnvironment.findMany lookup + // 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( @@ -370,7 +370,7 @@ describe("ApiRunListPresenter public /runs list (PG14 legacy + PG17 new)", () => } ); - // Step 4 / DoD "passthrough (single-DB)": two-arg-style construction (no readThroughDeps) -> + // 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( diff --git a/apps/webapp/test/batchListPresenter.readroute.test.ts b/apps/webapp/test/batchListPresenter.readroute.test.ts index 28ca158afa2..08b7b7a8fe2 100644 --- a/apps/webapp/test/batchListPresenter.readroute.test.ts +++ b/apps/webapp/test/batchListPresenter.readroute.test.ts @@ -226,7 +226,7 @@ function spyClient( 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)", () => { - // --- Task 3 Step 1: byte-identical scan + identical ORDER-BY across PG14/PG17 --- + // 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 }) => { @@ -292,7 +292,7 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P } ); - // --- Task 3 Step 2: split scan merge serves new + legacy in one keyset-ordered page --- + // 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 }) => { @@ -338,7 +338,7 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P } ); - // --- Task 3 Step 3: project resolves on control-plane; no cross-seam join --- + // 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 }) => { @@ -367,7 +367,7 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P } ); - // --- Task 3 Step 4: empty-state probe is dual-DB during the window --- + // 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 }) => { @@ -396,7 +396,7 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P } ); - // --- Task 3 Step 5: single-DB passthrough collapses to one handle --- + // 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 }) => { @@ -434,7 +434,6 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P } ); - // RED before fix: $queryRaw across clients → P2010/42601 "$1". GREEN after: typed findMany. heteroRunOpsPostgresTest( "scan against dedicated RunOpsPrismaClient (splitEnabled): returns batches from new DB", async ({ prisma14, prisma17 }) => { diff --git a/apps/webapp/test/batchPresenter.test.ts b/apps/webapp/test/batchPresenter.test.ts index 3173dbe46a3..151a721349f 100644 --- a/apps/webapp/test/batchPresenter.test.ts +++ b/apps/webapp/test/batchPresenter.test.ts @@ -137,7 +137,7 @@ function makeEnvResolver(controlPlane: PrismaClient) { } describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { - // DoD: batch detail resolves on run-ops NEW (split on). Legacy replica is never probed. + // 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 }) => { @@ -182,7 +182,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { } ); - // DoD: batch detail resolves on run-ops OLD/legacy READ REPLICA (split on, in-retention). + // 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", @@ -239,7 +239,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { } ); - // DoD: post-termination / not-found yields the normal "Batch not found". + // 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 }) => { @@ -259,7 +259,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { } ); - // DoD: env decoupling parity for a DEVELOPMENT env (userName branch). + // 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 }) => { @@ -287,7 +287,7 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => { }); describe("BatchPresenter single-DB passthrough", () => { - // DoD passthrough line + self-host collapse: one plain read, legacy closure never invoked. + // 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 }) => { diff --git a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts index ba76ab4bec2..c24825d298c 100644 --- a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -172,9 +172,9 @@ function throwingFindFirst(prisma: PrismaClient, label: string): PrismaClient { const callOptions = (ctx: SeedContext) => ({ projectId: ctx.projectId, pageSize: 10 }); 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 []. + // 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". --- + // 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 }) => { @@ -217,10 +217,10 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac } ); - // --- new-DB short-circuit. A run on NEW, legacy replica wrapped so its taskRun.findFirst + // 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. --- + // invoked. replicationContainerTest( "new-DB short-circuit: hasAnyRuns answered from the new DB without touching the legacy replica", async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => { @@ -263,8 +263,8 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac } ); - // --- genuinely empty. Nothing on either DB. Empty CH page. splitEnabled true. Both - // probes run and return null -> the true empty state is preserved. --- + // 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 }) => { @@ -301,11 +301,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac } ); - // --- passthrough single-DB (two-arg ctor). One `prisma`, seed a run, empty CH page. + // 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. --- + // 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 }) => { @@ -332,7 +332,7 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac } ); - // --- list hydrate flows through the routed store: split, non-empty CH id-set whose rows are + // 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 @@ -340,7 +340,7 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac // 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. --- + // 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 }) => { diff --git a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts index d76e8dd422c..99e60c392ad 100644 --- a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts +++ b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts @@ -202,7 +202,7 @@ function throwingLegacyReplica(prisma: PrismaClient): PrismaClient { } describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new)", () => { - // --- DoD line + payloadType parity: split union of NEW + legacy-replica, JSON-only, createdAt desc --- + // 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 }) => { @@ -312,7 +312,7 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- Old in-retention run served from the legacy READ REPLICA only (no legacyWriter field exists) --- + // 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 }) => { @@ -369,7 +369,7 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- Passthrough (single-DB): one plain store read, the legacy replica never touched --- + // 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 }) => { @@ -428,7 +428,7 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } ); - // --- SCHEDULED-source parity: same hydrate path, ScheduledRun mapping exercised --- + // 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 }) => { diff --git a/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts index ca15b00ea08..6e6928188c0 100644 --- a/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts +++ b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts @@ -130,7 +130,7 @@ function throwingTaskRunFindMany(prisma: PrismaClient): PrismaClient { } describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", () => { - // --- resolves the CH id-set with NO TaskRun PG hydrate --- + // 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 }) => { @@ -168,7 +168,7 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", } ); - // --- CH filter is split-neutral (ids independent of PG residency) --- + // 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 }) => { @@ -223,7 +223,7 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", } ); - // --- single-DB passthrough; no legacy/known-migrated probe on this path --- + // 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 }) => { @@ -255,7 +255,7 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", } ); - // --- a far-future straggler's id surfaces from the CH id-set --- + // 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 }) => { @@ -299,7 +299,7 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", } ); - // --- tag match is contains-ALL (tagsMatch: "all" -> hasAll), authoritative --- + // 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. @@ -345,7 +345,7 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", } ); - // --- environment scoping: the CH filter excludes other environments --- + // 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( diff --git a/apps/webapp/test/waitpointListPresenter.readroute.test.ts b/apps/webapp/test/waitpointListPresenter.readroute.test.ts index b1211a2c090..722d23a1158 100644 --- a/apps/webapp/test/waitpointListPresenter.readroute.test.ts +++ b/apps/webapp/test/waitpointListPresenter.readroute.test.ts @@ -143,7 +143,7 @@ function rawScan(prisma: PrismaClient, environmentId: string, direction: "forwar } describe("WaitpointListPresenter read-route", () => { - // Task 3 Step 5 + Task 3 Step 7: single-DB short-circuits to one handle (passthrough). + // 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"); @@ -198,7 +198,7 @@ describe("WaitpointListPresenter read-route", () => { expect(result2.success).toBe(true); }); - // Task 3 Step 1: raw paginated scan byte-identical + identical ORDER-BY across PG14/PG17. + // 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 }) => { @@ -249,7 +249,7 @@ describe("WaitpointListPresenter read-route", () => { } ); - // Task 3 Step 2 + Step 4: split scan merges migrated (new/PG17) + abandoned (legacy/PG14) tokens + // 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( @@ -337,7 +337,7 @@ describe("WaitpointListPresenter read-route", () => { } ); - // Task 3 Step 3: empty-state probe is dual-DB during the window (no false-empty), and reads only + // 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); @@ -395,7 +395,6 @@ describe("WaitpointListPresenter read-route", () => { } }); - // RED before fix: $queryRaw across clients → P2010/42601 "$1". GREEN after: typed findMany. heteroRunOpsPostgresTest( "scan against dedicated RunOpsPrismaClient (splitEnabled): returns waitpoints from new DB", async ({ prisma14, prisma17 }) => { diff --git a/apps/webapp/test/waitpointPresenter.readthrough.test.ts b/apps/webapp/test/waitpointPresenter.readthrough.test.ts index 33ee847d730..09b30791fca 100644 --- a/apps/webapp/test/waitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/waitpointPresenter.readthrough.test.ts @@ -229,11 +229,11 @@ const callArgs = (ctx: SeedContext, friendlyId: string) => ({ }); describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connected runs)", () => { - // --- Task 4 Step 2 (read half) / Step 3: new-DB short-circuit. Waitpoint on NEW (PG17), legacy + // 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. --- + // NEVER fall through to legacy. heteroPostgresTest( - "Step 3: waitpoint on the new DB resolves without touching the legacy replica", + "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"); @@ -260,11 +260,11 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec } ); - // --- Task 4 Step 6 (read half): single-DB passthrough. No read-through deps -> exactly one plain + // 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. --- + // (no second handle is injected). The connected-runs hydrate forwards `undefined` deps. heteroPostgresTest( - "Step 6: no read-through deps -> one plain findFirst on the single replica (passthrough)", + "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", { @@ -293,12 +293,12 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec }); describe("WaitpointPresenter connected-runs hydrate routed through read-through (PG14 + PG17 + CH)", () => { - // --- Task 4 Step 2 / DoD part B: waitpoint detail + connected runs resolve on run-ops NEW. The + // 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. --- + // so the connected-runs hydrate flows through the routed store. replicationContainerTest( - "Step 2: waitpoint + 2 connected runs resolve on the new DB via the routed hydrate", + "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 From 2b41afb0f5a5efd226925f3c8b347b7518358fe4 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 15:32:17 +0100 Subject: [PATCH 05/14] style(run-ops): apply oxfmt Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/BatchListPresenter.server.ts | 69 +++---- .../v3/WaitpointListPresenter.server.ts | 7 +- .../apiRunResultPresenter.readthrough.test.ts | 5 +- .../apiWaitpointPresenter.readthrough.test.ts | 10 +- .../test/batchListPresenter.readroute.test.ts | 31 ++- apps/webapp/test/batchPresenter.test.ts | 41 ++-- .../nextRunListPresenter.readthrough.test.ts | 24 ++- .../TestTaskPresenter.readthrough.test.ts | 1 - .../clickHouseRunListResolver.test.ts | 6 +- .../webapp/test/runPresenterReadRoute.test.ts | 75 +++---- .../waitpointListPresenter.readroute.test.ts | 185 ++++++++++++------ .../waitpointPresenter.readthrough.test.ts | 1 - ...aitpointTagListPresenter.readroute.test.ts | 51 +++-- 13 files changed, 315 insertions(+), 191 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 2ed5abb5303..b0b3e917c16 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -197,43 +197,38 @@ export class BatchListPresenter extends BasePresenter { } 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 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; diff --git a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts index dcd3220b509..24d18a76e43 100644 --- a/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointListPresenter.server.ts @@ -170,7 +170,8 @@ export class WaitpointListPresenter extends BasePresenter { } if (from !== undefined) { const fromDate = new Date(from); - createdAtGte = createdAtGte === undefined ? fromDate : fromDate > createdAtGte ? fromDate : createdAtGte; + createdAtGte = + createdAtGte === undefined ? fromDate : fromDate > createdAtGte ? fromDate : createdAtGte; } const createdAtLte: Date | undefined = to !== undefined ? new Date(to) : undefined; @@ -180,9 +181,7 @@ export class WaitpointListPresenter extends BasePresenter { where: { environmentId: environment.id, type: "MANUAL", - ...(cursor - ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } - : {}), + ...(cursor ? { id: direction === "forward" ? { lt: cursor } : { gt: cursor } } : {}), ...(id ? { friendlyId: id } : {}), ...(statusesToFilter.length ? { status: { in: statusesToFilter } } : {}), ...(filterOutputIsError !== undefined ? { outputIsError: filterOutputIsError } : {}), diff --git a/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts index 13357ea130a..3b7571f4f55 100644 --- a/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiRunResultPresenter.readthrough.test.ts @@ -82,10 +82,7 @@ async function seedEnv(prisma: PrismaClient, slug: string) { return { organization, project, environment }; } -async function seedWorker( - prisma: PrismaClient, - ctx: { environmentId: string; projectId: string } -) { +async function seedWorker(prisma: PrismaClient, ctx: { environmentId: string; projectId: string }) { const queue = await prisma.taskQueue.create({ data: { friendlyId: `queue_${idGenerator()}`, diff --git a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts index a59f84473b0..608f81d358c 100644 --- a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -225,7 +225,10 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre newClient: recording(prisma17).handle, legacyReplica: newLegacy.handle, }); - const migratedResult = await migratedPresenter.call(environmentArg(newEnv.environment), newId); + const migratedResult = await migratedPresenter.call( + environmentArg(newEnv.environment), + newId + ); expect(migratedResult.id).toBe(`waitpoint_${newId}`); expect(newLegacy.calls.length).toBe(0); @@ -241,7 +244,10 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre newClient: recording(prisma17).handle, legacyReplica: recording(prisma14).handle, }); - const retentionResult = await retentionPresenter.call(environmentArg(oldEnv.environment), oldId); + const retentionResult = await retentionPresenter.call( + environmentArg(oldEnv.environment), + oldId + ); expect(retentionResult.id).toBe(`waitpoint_${oldId}`); } ); diff --git a/apps/webapp/test/batchListPresenter.readroute.test.ts b/apps/webapp/test/batchListPresenter.readroute.test.ts index 08b7b7a8fe2..a66786d8429 100644 --- a/apps/webapp/test/batchListPresenter.readroute.test.ts +++ b/apps/webapp/test/batchListPresenter.readroute.test.ts @@ -13,7 +13,11 @@ vi.mock("~/db.server", () => ({ sqlDatabaseSchema: Prisma.sql(["public"]), })); -import { heteroPostgresTest, heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers"; +import { + heteroPostgresTest, + heteroRunOpsPostgresTest, + postgresTest, +} from "@internal/testcontainers"; import { PrismaClient } from "@trigger.dev/database"; import { RunOpsPrismaClient } from "@internal/run-ops-database"; import { @@ -34,7 +38,12 @@ type SeedContext = { // 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 } + opts: { + environmentId: string; + pageSize: number; + direction: "forward" | "backward"; + cursor?: string; + } ) { const { environmentId, pageSize, direction, cursor } = opts; const sqlDatabaseSchema = Prisma.sql(["public"]); @@ -178,7 +187,10 @@ async function createBatch( }); } -const baseCall = (ctx: SeedContext, overrides: Partial = {}): BatchListOptions => ({ +const baseCall = ( + ctx: SeedContext, + overrides: Partial = {} +): BatchListOptions => ({ projectId: ctx.projectId, environmentId: ctx.environmentId, userId: ctx.userId, @@ -201,7 +213,8 @@ function spyClient( if (trProp === "findMany") { return (...args: any[]) => { counts.findMany++; - if (opts.throwOnQueryRaw) throw new Error("batchTaskRun.findMany must not be invoked on this handle"); + if (opts.throwOnQueryRaw) + throw new Error("batchTaskRun.findMany must not be invoked on this handle"); return (trTarget as any).findMany(...args); }; } @@ -286,7 +299,11 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P // 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" }) + await rawScan(prisma17, { + environmentId: ctx17.environmentId, + pageSize: 50, + direction: "forward", + }) ).map((r) => r.id); expect(dbForward).toEqual([...allIds].sort(desc)); } @@ -389,7 +406,9 @@ describe("BatchListPresenter run-ops read routing (PG14 control-plane/legacy + P expect(page.hasAnyBatches).toBe(true); // Now wipe legacy too => both empty => hasAnyBatches false. - await prisma14.batchTaskRun.deleteMany({ where: { runtimeEnvironmentId: ctx.environmentId } }); + 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); diff --git a/apps/webapp/test/batchPresenter.test.ts b/apps/webapp/test/batchPresenter.test.ts index 151a721349f..1547d452018 100644 --- a/apps/webapp/test/batchPresenter.test.ts +++ b/apps/webapp/test/batchPresenter.test.ts @@ -333,27 +333,28 @@ describe("BatchPresenter single-DB passthrough", () => { // 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 - }); + 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 presenter = new BatchPresenter(prisma, prisma, { + splitEnabled: false, + newClient: prisma, + resolveDisplayableEnvironment: makeEnvResolver(prisma), + }); - const result = await presenter.call({ - environmentId: ctx.environmentId, - batchId: "batch_e2e3", - }); + const result = await presenter.call({ + environmentId: ctx.environmentId, + batchId: "batch_e2e3", + }); - expect(result.friendlyId).toBe("batch_e2e3"); - expect(result.runCount).toBe(10); - }); + 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 index c24825d298c..647482f9f17 100644 --- a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -206,7 +206,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac splitEnabled: true, }); - const result = await presenter.call(ctx.organizationId, ctx.environmentId, callOptions(ctx)); + const result = await presenter.call( + ctx.organizationId, + ctx.environmentId, + callOptions(ctx) + ); // CH id-set is empty within the page window, but the legacy probe finds the row. expect(result.runs).toHaveLength(0); @@ -253,7 +257,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac }); // 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)); + const result = await presenter.call( + ctx.organizationId, + ctx.environmentId, + callOptions(ctx) + ); expect(result.runs).toHaveLength(0); expect(result.hasAnyRuns).toBe(true); @@ -291,7 +299,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac splitEnabled: true, }); - const result = await presenter.call(ctx.organizationId, ctx.environmentId, callOptions(ctx)); + const result = await presenter.call( + ctx.organizationId, + ctx.environmentId, + callOptions(ctx) + ); expect(result.runs).toHaveLength(0); expect(result.hasAnyRuns).toBe(false); @@ -392,7 +404,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac splitEnabled: true, }); - const result = await presenter.call(ctx.organizationId, ctx.environmentId, callOptions(ctx)); + 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) diff --git a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts index 99e60c392ad..f4fc652ef26 100644 --- a/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts +++ b/apps/webapp/test/presenters/TestTaskPresenter.readthrough.test.ts @@ -511,5 +511,4 @@ describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new } } ); - }); diff --git a/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts index 6e6928188c0..9741d7f4a0a 100644 --- a/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts +++ b/apps/webapp/test/realtime/clickHouseRunListResolver.test.ts @@ -362,7 +362,11 @@ describe("ClickHouseRunListResolver (realtime run-list id-set, split-neutral)", 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 createRun( + prisma, + { ...ctx, environmentId: otherEnvId }, + { friendlyId: "run_otherEnv" } + ); await setTimeout(1500); diff --git a/apps/webapp/test/runPresenterReadRoute.test.ts b/apps/webapp/test/runPresenterReadRoute.test.ts index 43de91f3755..711ae75445d 100644 --- a/apps/webapp/test/runPresenterReadRoute.test.ts +++ b/apps/webapp/test/runPresenterReadRoute.test.ts @@ -124,46 +124,49 @@ async function seedRun( } describe("RunPresenter run read seam (single-DB, real PG)", () => { - postgresTest("passthrough resolves the run-detail header via the singleton", async ({ prisma }) => { - setCurrentPrisma(prisma); + 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 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 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, - }); + 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(); - }); + // 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)", diff --git a/apps/webapp/test/waitpointListPresenter.readroute.test.ts b/apps/webapp/test/waitpointListPresenter.readroute.test.ts index 722d23a1158..c1f7287ef43 100644 --- a/apps/webapp/test/waitpointListPresenter.readroute.test.ts +++ b/apps/webapp/test/waitpointListPresenter.readroute.test.ts @@ -22,10 +22,17 @@ vi.mock("~/db.server", async () => { }; }); -import { heteroPostgresTest, heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers"; +import { + heteroPostgresTest, + heteroRunOpsPostgresTest, + postgresTest, +} from "@internal/testcontainers"; import { Prisma, PrismaClient, type WaitpointStatus } from "@trigger.dev/database"; import { RunOpsPrismaClient } from "@internal/run-ops-database"; -import { WaitpointListPresenter, type WaitpointListOptions } from "~/presenters/v3/WaitpointListPresenter.server"; +import { + WaitpointListPresenter, + type WaitpointListOptions, +} from "~/presenters/v3/WaitpointListPresenter.server"; vi.setConfig({ testTimeout: 90_000 }); @@ -116,7 +123,12 @@ function baseOptions( } // 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) { +function rawScan( + prisma: PrismaClient, + environmentId: string, + direction: "forward" | "backward", + limit: number +) { const schema = Prisma.sql(["public"]); return prisma.$queryRaw` SELECT @@ -155,7 +167,11 @@ describe("WaitpointListPresenter read-route", () => { outputIsError: false, tags: ["b", "a"], }); - await seedWaitpoint(prisma, ctx, { id: "wp00000000000000000000003", status: "COMPLETED", outputIsError: true }); + 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" }); @@ -176,7 +192,10 @@ describe("WaitpointListPresenter read-route", () => { if (!result.success) return; // Page of 2, id DESC (forward). - expect(result.tokens.map((t) => t.id)).toEqual(["wp_wp00000000000000000000003", "wp_wp00000000000000000000002"]); + 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); @@ -203,11 +222,39 @@ describe("WaitpointListPresenter read-route", () => { "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") }, + { + 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" }, ]; @@ -264,7 +311,11 @@ describe("WaitpointListPresenter read-route", () => { // 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 }); + 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" }); @@ -339,61 +390,64 @@ describe("WaitpointListPresenter read-route", () => { // 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); - } + 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 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); - } + // Zero MANUAL on NEW, exactly one on LEGACY. + await seedWaitpoint(prisma14, ctx, { id: "wp30000000000000000000001" }); - // 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"); - }, + // 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); } - ) 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", @@ -427,7 +481,12 @@ describe("WaitpointListPresenter read-route", () => { }); const result = await presenter.call({ - environment: { id: envId, type: "PRODUCTION", project: { id: projId, engine: "V2" }, apiKey: "tr_prod_rawscan" }, + environment: { + id: envId, + type: "PRODUCTION", + project: { id: projId, engine: "V2" }, + apiKey: "tr_prod_rawscan", + }, }); expect(result.success).toBe(true); diff --git a/apps/webapp/test/waitpointPresenter.readthrough.test.ts b/apps/webapp/test/waitpointPresenter.readthrough.test.ts index 09b30791fca..4cb58092338 100644 --- a/apps/webapp/test/waitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/waitpointPresenter.readthrough.test.ts @@ -374,7 +374,6 @@ describe("WaitpointPresenter connected-runs hydrate routed through read-through } } ); - }); describe("WaitpointPresenter bare-ctor production default activates readThroughRun", () => { diff --git a/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts index 050612552bd..637d42ae643 100644 --- a/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts +++ b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts @@ -29,10 +29,7 @@ type LegacySeedContext = { environmentId: string; }; -async function seedLegacyParents( - prisma: PrismaClient, - slug: string -): Promise { +async function seedLegacyParents(prisma: PrismaClient, slug: string): Promise { const organization = await prisma.organization.create({ data: { title: `org-${slug}`, slug: `org-${slug}` }, }); @@ -105,15 +102,35 @@ describe("WaitpointTagListPresenter read-route", () => { 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 }, + { + 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 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, @@ -145,8 +162,18 @@ describe("WaitpointTagListPresenter read-route", () => { 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: "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 }, ], From 26e428e548e438659109156bb31c67240c934bcc Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 16:16:37 +0100 Subject: [PATCH 06/14] fix(run-ops split): drop dead connectedRuns relation from ApiWaitpointPresenter hydrate select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/waitpoints/tokens/:id returned HTTP 500 under split routing. The hydrate() select in ApiWaitpointPresenter included the connectedRuns RELATION, but it is never referenced in the returned object (all scalars) — dead code left over from the presenters de-join pass (48ae62b70), which stripped relations from WaitpointListPresenter but missed this one. Under split routing a standalone cuid token classifies LEGACY, so readThroughRun probes the scalar-only run-ops NEW client FIRST. The dedicated run-ops Waitpoint model is scalar-only (relations de-normalized), so Prisma threw PrismaClientValidationError: Unknown field connectedRuns before the legacy fallback could run. Removing the block makes the select all-scalar and valid against both the control-plane client and the scalar-only run-ops-new client. No return-shape, type, or SDK change. Regression test: extends apiWaitpointPresenter.readthrough.test.ts with a heteroRunOpsPostgresTest case that uses the REAL scalar-only run-ops client (prisma17) as the split-mode NEW client and seeds a cuid token on the legacy side. Fails before the fix (Unknown field connectedRuns), passes after (resolves via the legacy fallback). Not caught by the existing heteroPostgresTest cases because those run the full control-plane schema on both sides. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/ApiWaitpointPresenter.server.ts | 6 --- .../apiWaitpointPresenter.readthrough.test.ts | 48 ++++++++++++++++++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts index 9cb541c1dd0..c42371f632a 100644 --- a/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts @@ -65,12 +65,6 @@ export class ApiWaitpointPresenter extends BasePresenter { completedAfter: true, completedAt: true, createdAt: true, - connectedRuns: { - select: { - friendlyId: true, - }, - take: 5, - }, tags: true, }, }); diff --git a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts index 608f81d358c..1825947ea8d 100644 --- a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -2,7 +2,12 @@ // 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, postgresTest } from "@internal/testcontainers"; +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"; @@ -22,7 +27,7 @@ function generateLegacyCuid() { // 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, opts: { forbidden?: boolean } = {}) { +function recording(client: PrismaClient | RunOpsPrismaClient, opts: { forbidden?: boolean } = {}) { const calls: unknown[] = []; const waitpoint = { findFirst: (args: unknown) => { @@ -253,6 +258,45 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre ); }); +// 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", From 77a3d69e2570d8268dbad651a27e6a8a9f2e9561 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 18:34:54 +0100 Subject: [PATCH 07/14] test(run-ops): reconcile ApiBatchResultsPresenter split test with read-through presenter design The pr04-era ApiBatchResultsPresenter.split.test.ts asserted a bare `new ApiBatchResultsPresenter()` routes the batch-row lookup through the runStore singleton (via vi.mock("~/v3/runStore.server")) so a NEW-resident ksuid batch resolves. pr08's call() supersedes that contract: a bare presenter has readThrough === undefined, so splitEnabled is false and it takes #callPassthrough, which reads this._replica.batchTaskRun directly (not through runStore). The test therefore fails against pr08 and encodes a design that no longer exists. Both cases it covered are already covered by pr08's own apiBatchResultsPresenter.readthrough.test.ts against the current #callSplit API: (a) a NEW-resident batch resolving under split (batch row + items on the NEW DB, resolved via newClient) and (b) a genuinely-missing batch returning undefined (split on). Remove the redundant, superseded test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ApiBatchResultsPresenter.split.test.ts | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 apps/webapp/test/presenters/ApiBatchResultsPresenter.split.test.ts 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 4a75cf281d9..00000000000 --- 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(); - } -); From b9815cc2db31b3027af6e8a25cbca8126c081ce2 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 20:09:10 +0100 Subject: [PATCH 08/14] fix(run-ops split): align WaitpointPresenter single-DB fallback and repair read-side test shims - WaitpointPresenter: default omitted read-through clients to the presenter's own replica instead of the global run-ops singletons, so single-DB/self-host reads hydrate the waitpoint and its connected runs from one DB. - SpanPresenter test: bind proxy-returned store methods to the target so private field access holds for all methods (e.g. findRunOnPrimary), not just findRun/findRuns. - ApiRetrieveRunPresenter read-route test: select scalar lockedToVersionId and fold the resolved lockedToVersion per node, matching the presenter's current shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v3/WaitpointPresenter.server.ts | 12 ++++------- .../test/SpanPresenter.readthrough.test.ts | 7 ++++--- .../apiRetrieveRunPresenter.readroute.test.ts | 20 ++++++++++++++----- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index e582705b75a..a56c5e76ad0 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -1,10 +1,5 @@ import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3"; -import { - type PrismaClientOrTransaction, - type PrismaReplicaClient, - runOpsNewReplica, - runOpsLegacyReplica, -} from "~/db.server"; +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"; @@ -74,10 +69,11 @@ export class WaitpointPresenter extends BasePresenter { deps: { splitEnabled: this.readThroughDeps.splitEnabled, newClient: - (this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ?? runOpsNewReplica, + (this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ?? + (this._replica as unknown as PrismaReplicaClient), legacyReplica: (this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ?? - runOpsLegacyReplica, + (this._replica as unknown as PrismaReplicaClient), }, }); diff --git a/apps/webapp/test/SpanPresenter.readthrough.test.ts b/apps/webapp/test/SpanPresenter.readthrough.test.ts index 37ef0a661dd..6cf625e74fd 100644 --- a/apps/webapp/test/SpanPresenter.readthrough.test.ts +++ b/apps/webapp/test/SpanPresenter.readthrough.test.ts @@ -157,15 +157,16 @@ async function createRun( function ownDbStore(prisma: PrismaClient): RunStore { const inner = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); return new Proxy(inner, { - get(target, prop, receiver) { + 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)(...stripped); + return (target[prop] as (...a: unknown[]) => unknown).apply(target, stripped); }; } - return Reflect.get(target, prop, receiver); + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; }, }) as unknown as RunStore; } diff --git a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts index a5516687b51..48e1bcc7858 100644 --- a/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts +++ b/apps/webapp/test/apiRetrieveRunPresenter.readroute.test.ts @@ -60,7 +60,7 @@ const commonRunSelect = { scheduleId: true, workerQueue: true, region: true, - lockedToVersion: { select: { version: true } }, + lockedToVersionId: true, resumeParentOnCompletion: true, batch: { select: { id: true, friendlyId: true } }, runTags: true, @@ -84,18 +84,28 @@ const findRunSelect = { } satisfies Prisma.TaskRunSelect; // Drive the read exactly as `findRun` does: the RunStore.findRun contract over -// the given store with the presenter's where+select, mapping to FoundRun. +// 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( + const pgRow = (await store.findRun( { friendlyId, runtimeEnvironmentId }, { select: findRunSelect } - ); + )) as Record | null; if (!pgRow) return null; - return { ...(pgRow as Omit), isBuffered: false }; + 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) { From cad09dfbc284275bd13859c5add93f368555711c Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Thu, 2 Jul 2026 20:50:30 +0100 Subject: [PATCH 09/14] test(run-ops): stop RunPresenter read-seam test freezing on the first testcontainer The RunStore singleton is built once at import and its error-normalizing wrapper memoizes each Prisma model delegate on first access. The test's delegating proxy returned current.taskRun directly, so the store cached the first test's container-bound delegate and later tests (fresh containers) failed with "Database test_0 does not exist". Object-valued delegates now return a stable per-key sub-proxy the store can safely cache, re-delegating to the live current client on each access. Production code is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webapp/test/runPresenterReadRoute.test.ts | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/webapp/test/runPresenterReadRoute.test.ts b/apps/webapp/test/runPresenterReadRoute.test.ts index 711ae75445d..e3733ff8f27 100644 --- a/apps/webapp/test/runPresenterReadRoute.test.ts +++ b/apps/webapp/test/runPresenterReadRoute.test.ts @@ -11,10 +11,40 @@ import { describe, expect, vi } from "vitest"; vi.setConfig({ testTimeout: 60_000 }); -// Hoisted alongside the vi.mock factory. `setCurrentPrisma` is called at the start -// of each test to point the delegating Proxy at the real testcontainer client. +// 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( {}, { @@ -22,6 +52,13 @@ const { delegating, setCurrentPrisma } = vi.hoisted(() => { 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]; }, } From 3a0918dc2457909394145432e1ea72d0fe4bad29 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 02:11:40 +0100 Subject: [PATCH 10/14] test(run-ops): stub org-data-stores registry singleton + deterministic empty CH window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two webapp unit-test shards were red on this branch: - Shard 6 (apiRetrieveRunPresenter.readroute.test.ts): the organizationDataStoresRegistry singleton is constructed at import (pulled in transitively via the ClickHouse factory instance) and immediately fires a `forever` pRetry(loadFromDatabase) plus a setInterval reload against db.server's $replica. In CI (no Postgres on localhost) those retry forever and saturate the worker event loop, timing out an awaiting test's hook. Mock the instance module to a no-op in test/setup.ts, mirroring the other eager-singleton stubs. No unit test uses this singleton — the registry-behavior tests construct the class directly — so this is safe and should also help other shards/branches that import the presenter graph. - Shard 2 (nextRunListPresenter.readthrough.test.ts): the two empty-state tests seeded a run then assumed it had NOT yet replicated to ClickHouse within the page window, so result.runs would be empty. That held on a slow local stack but raced on CI, where replication had completed and the run surfaced (length 1, not 0). Scope those two calls to a `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 PG existence probe has no time filter, so it still finds the row and hasAnyRuns stays true — the exact behavior each test verifies. Reproduced both failures and verified the fix under a CI-faithful env (DATABASE_URL + REDIS at dead ports, GITHUB_ACTIONS=true, --no-file-parallelism): shard 2 now 18 files / 118 tests green, shard 6 now 16 files / 212 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextRunListPresenter.readthrough.test.ts | 20 ++++++++++++++++--- apps/webapp/test/setup.ts | 16 +++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts index 647482f9f17..41daf9834de 100644 --- a/apps/webapp/test/nextRunListPresenter.readthrough.test.ts +++ b/apps/webapp/test/nextRunListPresenter.readthrough.test.ts @@ -169,7 +169,17 @@ function throwingFindFirst(prisma: PrismaClient, label: string): PrismaClient { }) as unknown as PrismaClient; } -const callOptions = (ctx: SeedContext) => ({ projectId: ctx.projectId, pageSize: 10 }); +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 []. @@ -209,7 +219,7 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac const result = await presenter.call( ctx.organizationId, ctx.environmentId, - callOptions(ctx) + callOptions(ctx, emptyPageWindow()) ); // CH id-set is empty within the page window, but the legacy probe finds the row. @@ -337,7 +347,11 @@ describe("NextRunListPresenter dual-DB empty-state probe + routed hydrate (legac // (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)); + const result = await presenter.call( + ctx.organizationId, + ctx.environmentId, + callOptions(ctx, emptyPageWindow()) + ); expect(result.runs).toHaveLength(0); expect(result.hasAnyRuns).toBe(true); diff --git a/apps/webapp/test/setup.ts b/apps/webapp/test/setup.ts index 5824c15b900..4da9d260aa2 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() })); From e2a35ff42708ed9cbd6eac333404d2f029999440 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 08:51:13 +0100 Subject: [PATCH 11/14] chore: add server-changes for pr08 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../route-run-presenter-reads-through-run-store.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/route-run-presenter-reads-through-run-store.md 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 00000000000..962e72f95f8 --- /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. From 1ae68a04a3c3cffe4eb1770c17d6b9e5ad23f4c1 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 11:28:34 +0100 Subject: [PATCH 12/14] chore(run-ops): fix lint/format for main lint rules Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/webapp/app/presenters/v3/SpanPresenter.server.ts | 2 -- apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts | 2 +- apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts | 4 ++-- apps/webapp/test/batchListPresenter.readroute.test.ts | 4 ++-- apps/webapp/test/spanPresenterReadthroughDecompose.test.ts | 2 +- apps/webapp/test/waitpointListPresenter.readroute.test.ts | 4 ++-- apps/webapp/test/waitpointTagListPresenter.readroute.test.ts | 4 ++-- 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index dde88ccf373..ea2a89eaa1d 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -36,7 +36,6 @@ import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/tas import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus"; import { BasePresenter } from "./basePresenter.server"; import { WaitpointPresenter } from "./WaitpointPresenter.server"; -import type { SpanDetail } from "~/v3/eventRepository/eventRepository.types"; import { controlPlaneResolver, type ResolvedRunLockedWorker, @@ -94,7 +93,6 @@ export type Span = NonNullable["span"]>; type FindRunResult = NonNullable< Awaited["findRun"]>> >; -type GetSpanResult = SpanDetail; // 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 diff --git a/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts b/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts index cf8e8720bdb..a9dfb1a6e43 100644 --- a/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts +++ b/apps/webapp/test/apiWaitpointListPresenter.readroute.test.ts @@ -25,7 +25,7 @@ vi.mock("~/db.server", async () => { }); import { postgresTest } from "@internal/testcontainers"; -import { PrismaClient } from "@trigger.dev/database"; +import type { PrismaClient } from "@trigger.dev/database"; import { ApiWaitpointListPresenter } from "~/presenters/v3/ApiWaitpointListPresenter.server"; vi.setConfig({ testTimeout: 120_000 }); diff --git a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts index 1825947ea8d..58d3827f55c 100644 --- a/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts +++ b/apps/webapp/test/apiWaitpointPresenter.readthrough.test.ts @@ -181,7 +181,7 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre "not-found maps to the existing ServiceValidationError surface", async ({ prisma17, prisma14 }) => { const id = generateLegacyCuid(); - const { project, environment } = await seedOrgProjectEnv(prisma14, "nf"); + const { environment } = await seedOrgProjectEnv(prisma14, "nf"); const presenter = new ApiWaitpointPresenter(undefined, undefined, { splitEnabled: true, @@ -199,7 +199,7 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre "past-retention maps to the same not-found surface", async ({ prisma17, prisma14 }) => { const id = generateLegacyCuid(); - const { project, environment } = await seedOrgProjectEnv(prisma14, "pr"); + const { environment } = await seedOrgProjectEnv(prisma14, "pr"); const presenter = new ApiWaitpointPresenter(undefined, undefined, { splitEnabled: true, diff --git a/apps/webapp/test/batchListPresenter.readroute.test.ts b/apps/webapp/test/batchListPresenter.readroute.test.ts index a66786d8429..ee0db66dde2 100644 --- a/apps/webapp/test/batchListPresenter.readroute.test.ts +++ b/apps/webapp/test/batchListPresenter.readroute.test.ts @@ -18,8 +18,8 @@ import { heteroRunOpsPostgresTest, postgresTest, } from "@internal/testcontainers"; -import { PrismaClient } from "@trigger.dev/database"; -import { RunOpsPrismaClient } from "@internal/run-ops-database"; +import type { PrismaClient } from "@trigger.dev/database"; +import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { type BatchListOptions, BatchListPresenter, diff --git a/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts b/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts index 3144860f228..bbac387200d 100644 --- a/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts +++ b/apps/webapp/test/spanPresenterReadthroughDecompose.test.ts @@ -83,7 +83,7 @@ describe("SpanPresenter cross-DB read-through", () => { 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({ + const _run = await (prisma17 as unknown as PrismaClient).taskRun.create({ data: { id: `run_${n++}_pg17`, engine: "V2", diff --git a/apps/webapp/test/waitpointListPresenter.readroute.test.ts b/apps/webapp/test/waitpointListPresenter.readroute.test.ts index c1f7287ef43..49cf8749092 100644 --- a/apps/webapp/test/waitpointListPresenter.readroute.test.ts +++ b/apps/webapp/test/waitpointListPresenter.readroute.test.ts @@ -27,8 +27,8 @@ import { heteroRunOpsPostgresTest, postgresTest, } from "@internal/testcontainers"; -import { Prisma, PrismaClient, type WaitpointStatus } from "@trigger.dev/database"; -import { RunOpsPrismaClient } from "@internal/run-ops-database"; +import { Prisma, type PrismaClient, type WaitpointStatus } from "@trigger.dev/database"; +import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { WaitpointListPresenter, type WaitpointListOptions, diff --git a/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts index 637d42ae643..ced2648c8bc 100644 --- a/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts +++ b/apps/webapp/test/waitpointTagListPresenter.readroute.test.ts @@ -15,8 +15,8 @@ vi.mock("~/db.server", () => ({ })); import { heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers"; -import { PrismaClient } from "@trigger.dev/database"; -import { RunOpsPrismaClient } from "@internal/run-ops-database"; +import type { PrismaClient } from "@trigger.dev/database"; +import type { RunOpsPrismaClient } from "@internal/run-ops-database"; import { WaitpointTagListPresenter, type TagListOptions, From 77236dde52ed2ea55eecb9c432c9a925b2db6f9d Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 17:28:58 +0100 Subject: [PATCH 13/14] test(run-ops split): route-level regression for batch-results 404 on NEW-resident batch Adds the route-level sibling of the other readroute presenter tests. Seeds a NEW-resident (ksuid) batch + member on the NEW store and asserts the presenter wired as the route wires it (splitEnabled + newClient + legacyReplica) resolves it, while a passthrough-only build of the same presenter returns undefined -- the exact 404 the route produced before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...apiBatchResultsPresenter.readroute.test.ts | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts diff --git a/apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts b/apps/webapp/test/apiBatchResultsPresenter.readroute.test.ts new file mode 100644 index 00000000000..11c97dc2705 --- /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(); + } + ); +}); From 1ef21cfe8d227b34bda0886ce8c5e148ac0f2447 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Fri, 3 Jul 2026 20:12:18 +0100 Subject: [PATCH 14/14] fix(run-ops presenters): batch-list empty-state probe mirrors the scan's client In passthrough mode (readRoute provided but splitEnabled false) the empty-state probe read runOpsNew while the page scan reads _replica, so a configured-but-read-disabled run-ops DB could make the 'no batches' hint disagree with the page. Gate the probe on splitEnabled first. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../presenters/v3/BatchListPresenter.server.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index b0b3e917c16..8896b597fe6 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -111,19 +111,24 @@ export class BatchListPresenter extends BasePresenter { // 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 { - const onNew = await (this.readRoute?.runOpsNew ?? this._replica).batchTaskRun.findFirst({ + // 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; } - if (!this.readRoute?.splitEnabled) { - return false; - } - const onLegacy = await ( - this.readRoute?.runOpsLegacyReplica ?? this._replica + this.readRoute.runOpsLegacyReplica ?? this._replica ).batchTaskRun.findFirst({ where: { runtimeEnvironmentId: environmentId }, });