feat(webapp,run-store): route run-graph reads and writes through the run-store router (#4237)
## Summary Run-graph data (runs, batches, waitpoints, and their related tables) can now live in a database separate from the control plane, with every read and write routed to the correct database by each run's residency. This makes reading and writing run data more reliable once the two are split, and is a no-op for single-database installs. ## Design - Run-graph table access goes through the run-store router, which selects the legacy or the new run-ops store per run instead of assuming one shared client. - The legacy run-ops client is now independently pointable, so legacy run data can be served from its own database (and replica) rather than the control-plane connection. - Run-graph writes go straight to the run-graph database instead of being forwarded through the control plane, and replication targets are split so runs in the new database still replicate to analytics without under-counting. - Read-through slots refuse the control-plane client, so a missing residency fails loudly instead of silently reading the wrong database. - Migration `20260710120000_drop_remaining_run_graph_seam_foreign_keys` drops the foreign keys that still crossed the run-graph / control-plane seam, which is what lets the two live in separate databases. The split stays off unless explicitly enabled and the two databases are confirmed physically distinct; startup fails closed otherwise. Verified by running the full dashboard end-to-end suite against both a single-database configuration and a three-database configuration (control plane, the new database, and a physically separate legacy database), with runs on both residencies. No misrouted reads in either configuration.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Improved the reliability of how run data is read and written.
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
assertSplitRealtimeInterlock,
|
||||
} from "./v3/runOpsMigration/splitMode.server";
|
||||
import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate";
|
||||
import { assertControlPlaneCoresidencyAdvisory } from "./v3/runOpsMigration/controlPlaneCoresidencySentinel.server";
|
||||
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
|
||||
import type { Span } from "@opentelemetry/api";
|
||||
import { context, trace } from "@opentelemetry/api";
|
||||
@@ -188,6 +189,7 @@ export type RunOpsTopology = {
|
||||
export type SelectRunOpsTopologyConfig = {
|
||||
splitEnabled: boolean;
|
||||
legacyUrl?: string;
|
||||
legacyReplicaUrl?: string;
|
||||
newUrl?: string;
|
||||
newReplicaUrl?: string;
|
||||
};
|
||||
@@ -195,6 +197,10 @@ export type RunOpsClientBuilders = {
|
||||
controlPlane: RunOpsClients;
|
||||
buildNewWriter: (url: string, clientType: string) => RunOpsPrismaClient;
|
||||
buildNewReplica: (url: string, clientType: string) => RunOpsPrismaClient;
|
||||
// Legacy builders return the same PrismaClient/PrismaReplicaClient types as the control plane (no
|
||||
// RunOpsPrismaClient double-cast needed): the legacy DB carries the full control-plane schema.
|
||||
buildLegacyWriter: (url: string, clientType: string) => PrismaClient;
|
||||
buildLegacyReplica: (url: string, clientType: string) => PrismaReplicaClient;
|
||||
};
|
||||
|
||||
// Pure run-ops client selector. No env, no isSplitEnabled() — those
|
||||
@@ -220,7 +226,15 @@ export function selectRunOpsTopology(
|
||||
return { newRunOps: cpFallback, legacyRunOps: controlPlane, controlPlane };
|
||||
}
|
||||
|
||||
const legacyRunOps = controlPlane;
|
||||
// Track 2: build an INDEPENDENT legacy client from its own DSN instead of aliasing the control
|
||||
// plane. legacyUrl is guaranteed present (the missing-URL branch above aliases and returns).
|
||||
const legacyWriter = builders.buildLegacyWriter(config.legacyUrl, "run-ops-legacy-writer");
|
||||
// Mirror the NEW replica + control-plane $replica fallback: brand a real replica (in the builder),
|
||||
// otherwise reuse the legacy WRITER so replica reads fall back to the legacy primary — unbranded.
|
||||
const legacyReplica: PrismaReplicaClient = config.legacyReplicaUrl
|
||||
? builders.buildLegacyReplica(config.legacyReplicaUrl, "run-ops-legacy-reader")
|
||||
: legacyWriter;
|
||||
const legacyRunOps: RunOpsClients = { writer: legacyWriter, replica: legacyReplica };
|
||||
|
||||
const newWriter = builders.buildNewWriter(config.newUrl, "run-ops-new-writer");
|
||||
const newReplica: RunOpsPrismaClient = config.newReplicaUrl
|
||||
@@ -246,10 +260,19 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
|
||||
// Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on.
|
||||
const splitEnabled = env.RUN_OPS_SPLIT_ENABLED && !!newUrl && !!env.RUN_OPS_LEGACY_DATABASE_URL;
|
||||
|
||||
// Without a dedicated legacy replica URL, legacy reads fall back to the legacy WRITER (primary).
|
||||
// Surface that so a prod misdeploy is observable instead of a silent load shift onto the primary.
|
||||
if (splitEnabled && !env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL) {
|
||||
logger.warn(
|
||||
"RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is unset while split is enabled; legacy reads will hit the legacy primary"
|
||||
);
|
||||
}
|
||||
|
||||
return selectRunOpsTopology(
|
||||
{
|
||||
splitEnabled,
|
||||
legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL,
|
||||
legacyReplicaUrl: env.RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL,
|
||||
newUrl,
|
||||
newReplicaUrl: env.RUN_OPS_DATABASE_READ_REPLICA_URL,
|
||||
},
|
||||
@@ -268,6 +291,18 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => {
|
||||
tagDatasourceRunOps("replica", buildRunOpsReplicaClient({ url, clientType }))
|
||||
)
|
||||
),
|
||||
// Legacy client shares the exact control-plane wrapper stack (the legacy DB carries the full
|
||||
// control-plane schema); markReadReplicaClient only on a real replica URL, as with the NEW replica.
|
||||
buildLegacyWriter: (url, clientType) =>
|
||||
captureInfrastructureErrors(
|
||||
tagDatasource("writer", buildWriterClient({ url, clientType }))
|
||||
),
|
||||
buildLegacyReplica: (url, clientType) =>
|
||||
markReadReplicaClient(
|
||||
captureInfrastructureErrors(
|
||||
tagDatasource("replica", buildReplicaClient({ url, clientType }))
|
||||
)
|
||||
),
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -281,8 +316,17 @@ export const runOpsNewPrisma: PrismaClient = runOpsTopology.newRunOps
|
||||
.writer as unknown as PrismaClient;
|
||||
export const runOpsNewReplica: PrismaReplicaClient = runOpsTopology.newRunOps
|
||||
.replica as unknown as PrismaReplicaClient;
|
||||
// Track 2: under split-on these point at the INDEPENDENT legacy client (its own DSN); under split-off
|
||||
// or missing URLs they still alias the control-plane client, so single-DB installs are unchanged.
|
||||
export const runOpsLegacyPrisma: PrismaClient = runOpsTopology.legacyRunOps.writer;
|
||||
export const runOpsLegacyReplica: PrismaReplicaClient = runOpsTopology.legacyRunOps.replica;
|
||||
// Branded legacy handles typed as RunOpsPrismaClient for the run-store boundary — same underlying
|
||||
// legacy writer/replica as runOpsLegacyPrisma/runOpsLegacyReplica above, but carrying the run-ops
|
||||
// brand so the guard classifies provably-legacy access as `runops`, not `cp`.
|
||||
export const runOpsLegacyPrismaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
|
||||
.writer as unknown as RunOpsPrismaClient;
|
||||
export const runOpsLegacyReplicaClient: RunOpsPrismaClient = runOpsTopology.legacyRunOps
|
||||
.replica as unknown as RunOpsPrismaClient;
|
||||
|
||||
export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
|
||||
newReplica: runOpsNewReplicaClient,
|
||||
@@ -295,8 +339,8 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
|
||||
|
||||
// Boot-time interlock: if the flag is on but the distinct-DB sentinel does not
|
||||
// confirm two physically-distinct run-ops DBs, refuse to enable split (data-loss
|
||||
// interlock). Async, so it cannot live in the synchronous singleton factory —
|
||||
// call it from the eager-boot path before any run-ops routing is wired.
|
||||
// interlock). Async, so it cannot live in the synchronous singleton factory — called
|
||||
// fire-and-forget from the eager-boot path (routing is wired synchronously at module load).
|
||||
export async function assertRunOpsSplitSentinel(): Promise<void> {
|
||||
if (!env.RUN_OPS_SPLIT_ENABLED) return;
|
||||
// Realtime interlock (synchronous): Electric replicates only from the control-plane
|
||||
@@ -312,6 +356,9 @@ export async function assertRunOpsSplitSentinel(): Promise<void> {
|
||||
"RUN_OPS_SPLIT_ENABLED is on but the distinct-DB sentinel did not confirm two physically-distinct run-ops DBs; refusing to enable split (data-loss interlock)."
|
||||
);
|
||||
}
|
||||
// Advisory-only (T2.3): observe legacy vs control-plane co-residency. Emits a metric + log and only
|
||||
// throws when RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT is on AND co-residency is positively confirmed.
|
||||
await assertControlPlaneCoresidencyAdvisory();
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
|
||||
@@ -138,8 +138,10 @@ const EnvironmentSchema = z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_URL is invalid")
|
||||
.optional(),
|
||||
// The LEGACY run-ops DB (the control-plane DB during the transition). When unset, legacy
|
||||
// run-ops reuses the existing DATABASE_URL (legacy run-ops == control-plane DB initially).
|
||||
// The LEGACY run-ops DB. Now a CONNECTED DSN (Track 2): when split is on and this is set it builds
|
||||
// an INDEPENDENT legacy Prisma client, no longer an alias of the control-plane client (nor merely
|
||||
// the sentinel's probe target). Unset -> legacy reuses the control-plane client / DATABASE_URL, so
|
||||
// single-DB and self-host installs boot byte-identical.
|
||||
RUN_OPS_LEGACY_DATABASE_URL: z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_URL is invalid")
|
||||
@@ -151,6 +153,22 @@ const EnvironmentSchema = z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_OPS_DATABASE_READ_REPLICA_URL is invalid")
|
||||
.optional(),
|
||||
// The LEGACY run-ops DB read replica (Track 2). Unset -> the legacy replica handle falls back to the
|
||||
// legacy WRITER (as $replica does with no CP replica). Set in production so legacy reads hit the reader.
|
||||
RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL: z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DATABASE_READ_REPLICA_URL is invalid")
|
||||
.optional(),
|
||||
// Direct DSN for applying the full @trigger.dev/database migrations to the LEGACY run-ops DB, keeping
|
||||
// its schema current after the control plane moves off it. Direct, not pooled — migrations never run
|
||||
// over a pooler. Optional; unset -> the entrypoint's legacy migrate step is skipped.
|
||||
RUN_OPS_LEGACY_DIRECT_URL: z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_OPS_LEGACY_DIRECT_URL is invalid")
|
||||
.optional(),
|
||||
// Advisory control-plane co-residency sentinel enforcement (Track 2, T2.3). Default OFF; the advisory
|
||||
// arm always emits its metric, this only turns a still-co-resident pair into a hard boot failure.
|
||||
RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT: BoolEnv.default(false),
|
||||
// --- Control-plane datasource repoint. Additive-only. ---
|
||||
// Optional control-plane DB. Unset (self-host/single-DB) -> getClient()/getReplicaClient() fall back to
|
||||
// DATABASE_URL/DATABASE_READ_REPLICA_URL, so boot is byte-identical. When set, these point at the
|
||||
@@ -1618,6 +1636,14 @@ const EnvironmentSchema = z
|
||||
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
|
||||
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
|
||||
|
||||
// Connection URL for the LEGACY runs-replication source (the runs-CDC slot on the legacy runs DB, plus
|
||||
// the admin recovery route). Direct, not pooled: replication can't run over a pooler. Optional; unset ->
|
||||
// falls back to DATABASE_URL, so nothing changes today.
|
||||
RUN_REPLICATION_LEGACY_DATABASE_URL: z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "RUN_REPLICATION_LEGACY_DATABASE_URL is invalid")
|
||||
.optional(),
|
||||
|
||||
// --- Run-ops DB split — second replication source (the NEW dedicated run-ops DB). ---
|
||||
// Cloud-only; only consulted when isSplitEnabled() is true. Self-host never sets these.
|
||||
// Connection URL for the run-ops DB used by the runs-replication source. Required when the split is
|
||||
@@ -1658,6 +1684,12 @@ const EnvironmentSchema = z
|
||||
SESSION_REPLICATION_PUBLICATION_NAME: z
|
||||
.string()
|
||||
.default("sessions_to_clickhouse_v1_publication"),
|
||||
// Connection URL for the sessions-replication slot. Direct, not pooled: replication can't run over a
|
||||
// pooler. Optional; unset -> falls back to DATABASE_URL, so nothing changes today.
|
||||
SESSION_REPLICATION_DATABASE_URL: z
|
||||
.string()
|
||||
.refine(isValidDatabaseUrl, "SESSION_REPLICATION_DATABASE_URL is invalid")
|
||||
.optional(),
|
||||
SESSION_REPLICATION_MAX_FLUSH_CONCURRENCY: z.coerce.number().int().default(1),
|
||||
SESSION_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
SESSION_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
|
||||
export const MAX_TAGS_PER_WAITPOINT = 10;
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -19,19 +19,10 @@ export async function createWaitpointTag({
|
||||
|
||||
while (attempts < MAX_RETRIES) {
|
||||
try {
|
||||
return await prisma.waitpointTag.upsert({
|
||||
where: {
|
||||
environmentId_name: {
|
||||
environmentId,
|
||||
name: tag,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
update: {},
|
||||
return await runStore.upsertWaitpointTag({
|
||||
environmentId,
|
||||
name: tag,
|
||||
projectId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.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.
|
||||
// LEGACY RUN-OPS READ REPLICA ONLY (never a writer/primary); defaults to runOpsLegacyReplica
|
||||
// (the Aurora legacy read replica), never the control-plane replica.
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
isPastRetention?: (runId: string) => boolean;
|
||||
};
|
||||
@@ -17,7 +18,8 @@ export class ApiRunResultPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
private readonly _readThrough?: ApiRunResultReadThroughDeps
|
||||
private readonly _readThrough?: ApiRunResultReadThroughDeps,
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(prisma, replica);
|
||||
}
|
||||
@@ -27,31 +29,14 @@ export class ApiRunResultPresenter extends BasePresenter {
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<TaskRunExecutionResult | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const findRun = (client: PrismaReplicaClient) =>
|
||||
client.taskRun.findFirst({
|
||||
where: { friendlyId, runtimeEnvironmentId: env.id },
|
||||
include: { attempts: { orderBy: { createdAt: "desc" } } },
|
||||
});
|
||||
|
||||
// Single-run result poll routed through run-ops read-through. Split on: primary store first,
|
||||
// then the secondary read replica for runs that miss on new; past-retention ids return
|
||||
// undefined -> the route's normal 404. Split off (single-DB / self-host): readThroughRun does
|
||||
// one plain findFirst against the single client (passthrough).
|
||||
const result = await readThroughRun({
|
||||
runId: friendlyId,
|
||||
environmentId: env.id,
|
||||
readNew: findRun,
|
||||
readLegacy: findRun,
|
||||
deps: {
|
||||
splitEnabled: this._readThrough?.splitEnabled,
|
||||
newClient: this._readThrough?.newClient ?? (this._prisma as PrismaReplicaClient),
|
||||
legacyReplica: this._readThrough?.legacyReplica ?? (this._replica as PrismaReplicaClient),
|
||||
isPastRetention: this._readThrough?.isPastRetention,
|
||||
},
|
||||
});
|
||||
|
||||
const taskRun =
|
||||
result.source === "new" || result.source === "legacy-replica" ? result.value : undefined;
|
||||
// Single-run result poll routed through the run store, which selects the owning DB by
|
||||
// run-id residency (id shape): a run-ops (NEW) id reads the new store, a cuid (LEGACY) id
|
||||
// reads the legacy store. Single-DB / self-host collapses to one plain findFirst against the
|
||||
// one store (passthrough). The identical TaskRun(+attempts) lookup runs inside the router.
|
||||
const taskRun = await this.runStore.findRun(
|
||||
{ friendlyId, runtimeEnvironmentId: env.id },
|
||||
{ include: { attempts: { orderBy: { createdAt: "desc" } } } }
|
||||
);
|
||||
|
||||
if (!taskRun) {
|
||||
return undefined;
|
||||
|
||||
@@ -5,11 +5,11 @@ 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 { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
|
||||
// When omitted, clients default to the inherited _replica handle => passthrough reads the
|
||||
// replica exactly as today. isPastRetention is injectable for tests. Typed PrismaReplicaClient
|
||||
// to match readThroughRun's readNew/readLegacy + deps.
|
||||
// Retained only to preserve the public constructor signature the route passes. Run-ops routing
|
||||
// (NEW vs LEGACY residency, replica reads) is now handled inside the injected `runStore`, so
|
||||
// these deps are no longer consulted for the read.
|
||||
type ApiWaitpointPresenterReadThroughDeps = {
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
@@ -21,7 +21,8 @@ export class ApiWaitpointPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps
|
||||
private readonly readThroughDeps?: ApiWaitpointPresenterReadThroughDeps,
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
@@ -39,56 +40,32 @@ export class ApiWaitpointPresenter extends BasePresenter {
|
||||
waitpointId: string
|
||||
) {
|
||||
return this.trace("call", async (span) => {
|
||||
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
|
||||
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
|
||||
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
|
||||
// (passthrough). The waitpointId is the residency-classifiable run-ops id (the route
|
||||
// pre-decodes the friendlyId via WaitpointId.toId).
|
||||
const hydrate = (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: waitpointId,
|
||||
environmentId: environment.id,
|
||||
readNew: (client) => hydrate(client),
|
||||
readLegacy: (replica) => hydrate(replica),
|
||||
deps: {
|
||||
splitEnabled: this.readThroughDeps?.splitEnabled,
|
||||
// Default both clients to the inherited _replica handle (declared
|
||||
// PrismaClientOrTransaction but $replica at runtime) so passthrough reads the replica
|
||||
// as today; split mode injects a distinct newClient.
|
||||
newClient: this.readThroughDeps?.newClient ?? (this._replica as PrismaReplicaClient),
|
||||
legacyReplica:
|
||||
this.readThroughDeps?.legacyReplica ?? (this._replica as PrismaReplicaClient),
|
||||
isPastRetention: this.readThroughDeps?.isPastRetention,
|
||||
// The store routes by the waitpointId's residency (id shape) and reads the owning
|
||||
// store's replica. waitpointId is pre-decoded from the friendlyId via WaitpointId.toId.
|
||||
const waitpoint = await this.runStore.findWaitpoint({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
idempotencyKey: true,
|
||||
userProvidedIdempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
inactiveIdempotencyKey: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
outputIsError: true,
|
||||
completedAfter: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const waitpoint =
|
||||
result.source === "new" || result.source === "legacy-replica" ? result.value : null;
|
||||
|
||||
if (!waitpoint) {
|
||||
logger.error(`WaitpointPresenter: Waitpoint not found`, {
|
||||
id: waitpointId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { type RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import parse from "parse-duration";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
@@ -51,8 +52,8 @@ export class BatchListPresenter extends BasePresenter {
|
||||
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
|
||||
runOpsNew?: RunOpsPrismaClient; // new run-ops client (run-ops brand ⇒ guard classifies as runops)
|
||||
runOpsLegacyReplica?: RunOpsPrismaClient; // legacy run-ops READ REPLICA only — never the legacy primary
|
||||
controlPlaneReplica?: PrismaClientOrTransaction; // control-plane DB (for project)
|
||||
splitEnabled?: boolean; // resolved boot constant
|
||||
}
|
||||
@@ -74,20 +75,25 @@ export class BatchListPresenter extends BasePresenter {
|
||||
async #scanBatchTaskRun(
|
||||
pageSize: number,
|
||||
direction: Direction,
|
||||
scan: (client: PrismaClientOrTransaction) => Promise<BatchRow[]>
|
||||
scan: (client: RunOpsPrismaClient) => Promise<BatchRow[]>
|
||||
): Promise<BatchRow[]> {
|
||||
// Single-DB / passthrough: `_replica` IS the run-ops database (same physical DB), so it is the
|
||||
// run-ops read handle. Carry the run-ops brand — identical wiring, correct residency — and it
|
||||
// also backstops a split deployment that omitted a routed handle (never the legacy primary).
|
||||
const passthrough = this._replica as unknown as RunOpsPrismaClient;
|
||||
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica);
|
||||
return scan(passthrough);
|
||||
}
|
||||
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? passthrough);
|
||||
|
||||
// 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);
|
||||
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? passthrough);
|
||||
|
||||
// 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.
|
||||
@@ -111,16 +117,20 @@ 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<boolean> {
|
||||
// Passthrough: probe the SAME client the scan uses (_replica), or the empty-state hint can
|
||||
// disagree with the page when a run-ops DB is configured but read-split is off.
|
||||
// Single-DB / passthrough: `_replica` IS the run-ops database, and it is the SAME client the
|
||||
// scan uses, so the empty-state hint can't disagree with the page. Carry the run-ops brand
|
||||
// (identical wiring, correct residency) and backstop a split deployment that omitted a routed
|
||||
// handle (never the legacy primary).
|
||||
const passthrough = this._replica as unknown as RunOpsPrismaClient;
|
||||
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
const onReplica = await this._replica.batchTaskRun.findFirst({
|
||||
const onReplica = await passthrough.batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
return Boolean(onReplica);
|
||||
}
|
||||
|
||||
const onNew = await (this.readRoute.runOpsNew ?? this._replica).batchTaskRun.findFirst({
|
||||
const onNew = await (this.readRoute.runOpsNew ?? passthrough).batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
if (onNew) {
|
||||
@@ -128,7 +138,7 @@ export class BatchListPresenter extends BasePresenter {
|
||||
}
|
||||
|
||||
const onLegacy = await (
|
||||
this.readRoute.runOpsLegacyReplica ?? this._replica
|
||||
this.readRoute.runOpsLegacyReplica ?? passthrough
|
||||
).batchTaskRun.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type BatchTaskRunStatus, type Prisma } from "@trigger.dev/database";
|
||||
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
@@ -11,22 +11,7 @@ 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,
|
||||
const BATCH_INCLUDE = {
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -40,16 +25,9 @@ const BATCH_SELECT = {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.BatchTaskRunSelect;
|
||||
|
||||
type BatchRow = Prisma.BatchTaskRunGetPayload<{ select: typeof BATCH_SELECT }>;
|
||||
} satisfies Prisma.BatchTaskRunInclude;
|
||||
|
||||
type BatchPresenterDeps = {
|
||||
/** Resolved boot constant; never awaited per-request when supplied. */
|
||||
splitEnabled?: boolean;
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
readThrough?: typeof readThroughRun;
|
||||
resolveDisplayableEnvironment?: typeof findDisplayableEnvironment;
|
||||
};
|
||||
|
||||
@@ -59,43 +37,22 @@ export class BatchPresenter extends BasePresenter {
|
||||
constructor(
|
||||
_prisma?: PrismaClientOrTransaction,
|
||||
_replica?: PrismaClientOrTransaction,
|
||||
private readonly deps: BatchPresenterDeps = {}
|
||||
private readonly deps: BatchPresenterDeps = {},
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(_prisma, _replica);
|
||||
}
|
||||
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
// Reads the BatchTaskRun (run-ops) via the read-through layer: split on -> new run-ops
|
||||
// first, then the LEGACY RUN-OPS READ REPLICA only for not-yet-migrated batches (never the
|
||||
// legacy primary); split off (single-DB / self-host) -> one plain batchTaskRun.findFirst
|
||||
// (passthrough). The runtimeEnvironment (control-plane) is resolved separately because its
|
||||
// FK is physically dropped on cloud, so a batch row on the new run-ops DB cannot single-SQL
|
||||
// join to control-plane RuntimeEnvironment.
|
||||
const where = { runtimeEnvironmentId: environmentId, friendlyId: batchId } as const;
|
||||
const readBatch = (client: PrismaReplicaClient): Promise<BatchRow | null> =>
|
||||
client.batchTaskRun.findFirst({ select: BATCH_SELECT, where });
|
||||
|
||||
const readThrough = this.deps.readThrough ?? readThroughRun;
|
||||
const batchResult = await readThrough<BatchRow>({
|
||||
// The read-through key; here it is the batch friendlyId. A cuid-shaped batch friendlyId
|
||||
// classifies as LEGACY and the read-through probes both stores (new first, then legacy
|
||||
// replica); a run-ops-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,
|
||||
// The BatchTaskRun (run-ops) is read through the run store, which routes by residency. The
|
||||
// runtimeEnvironment (control-plane) is resolved separately because the cross-seam FK is
|
||||
// dropped, so the batch row cannot single-SQL join to control-plane RuntimeEnvironment.
|
||||
const batch = await this.runStore.findBatchTaskRunByFriendlyId(
|
||||
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
|
||||
{ include: BATCH_INCLUDE },
|
||||
this._replica
|
||||
);
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
@@ -117,7 +74,6 @@ 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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "@trigger.dev/database";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
export type PlaygroundAgent = {
|
||||
@@ -120,31 +121,45 @@ export class PlaygroundPresenter {
|
||||
lastEventId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
run: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
runId: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return conversations.map((c) => ({
|
||||
id: c.id,
|
||||
chatId: c.chatId,
|
||||
title: c.title,
|
||||
agentSlug: c.agentSlug,
|
||||
runFriendlyId: c.run?.friendlyId ?? null,
|
||||
runStatus: c.run?.status ?? null,
|
||||
clientData: c.clientData,
|
||||
messages: c.messages,
|
||||
lastEventId: c.lastEventId,
|
||||
isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
// The conversation->run relation crosses the run-graph seam, so we resolve the backing
|
||||
// run's scalars via the run-store instead of relation-joining. `findRuns` routes the
|
||||
// (possibly mixed-residency) id set to the correct store(s) by id shape.
|
||||
const runIds = conversations.map((c) => c.runId).filter((id): id is string => id !== null);
|
||||
|
||||
const runsById = new Map<string, { friendlyId: string; status: TaskRunStatus }>();
|
||||
if (runIds.length > 0) {
|
||||
const runs = await runStore.findRuns({
|
||||
where: { id: { in: runIds } },
|
||||
select: { id: true, friendlyId: true, status: true },
|
||||
});
|
||||
for (const run of runs) {
|
||||
runsById.set(run.id, { friendlyId: run.friendlyId, status: run.status });
|
||||
}
|
||||
}
|
||||
|
||||
return conversations.map((c) => {
|
||||
const run = c.runId ? (runsById.get(c.runId) ?? null) : null;
|
||||
return {
|
||||
id: c.id,
|
||||
chatId: c.chatId,
|
||||
title: c.title,
|
||||
agentSlug: c.agentSlug,
|
||||
runFriendlyId: run?.friendlyId ?? null,
|
||||
runStatus: run?.status ?? null,
|
||||
clientData: c.clientData,
|
||||
messages: c.messages,
|
||||
lastEventId: c.lastEventId,
|
||||
isActive: run?.status ? !isFinalRunStatus(run.status) : false,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@trigger.dev/core/v3";
|
||||
|
||||
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { $replica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import {
|
||||
extractIdempotencyKeyScope,
|
||||
getUserProvidedIdempotencyKey,
|
||||
@@ -725,7 +725,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
legacyReplica: runOpsLegacyReplica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const waitpoint = await presenter.call({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenF
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { type WaitpointTokenStatus, type WaitpointTokenItem } from "@trigger.dev/core/v3";
|
||||
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
@@ -82,17 +83,19 @@ 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.
|
||||
// `readRoute` is retained only for caller-signature compatibility (see
|
||||
// ApiWaitpointListPresenter and the waitpoints-tokens route). Run-graph reads now go
|
||||
// through the shared `runStore`, which resolves residency (new vs legacy) itself and
|
||||
// reads the single DB when the split is off — so this hint is no longer consulted here.
|
||||
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
|
||||
}
|
||||
runOpsNew?: PrismaClientOrTransaction;
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction;
|
||||
splitEnabled?: boolean;
|
||||
},
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
@@ -176,8 +179,8 @@ export class WaitpointListPresenter extends BasePresenter {
|
||||
const createdAtLte: Date | undefined = to !== undefined ? new Date(to) : undefined;
|
||||
|
||||
const tokens = await this.#scanWaitpoints(
|
||||
(client) =>
|
||||
client.waitpoint.findMany({
|
||||
() =>
|
||||
this.runStore.findManyWaitpoints({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
type: "MANUAL",
|
||||
@@ -288,66 +291,27 @@ 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.
|
||||
// `runStore` returns the new+legacy union deduped by id but unsorted/unwindowed, so
|
||||
// re-apply the page's keyset order + over-fetch window to match a single union scan.
|
||||
async #scanWaitpoints(
|
||||
scan: (client: PrismaClientOrTransaction) => Promise<WaitpointRow[]>,
|
||||
scan: () => Promise<WaitpointRow[]>,
|
||||
pageSize: number,
|
||||
direction: Direction
|
||||
): Promise<WaitpointRow[]> {
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica);
|
||||
}
|
||||
|
||||
const overfetch = pageSize + 1;
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica);
|
||||
|
||||
// New DB filled the page => any older tokens fall on a later page; keep the
|
||||
// legacy read off the hot path. Presence on the new DB is the migrated signal.
|
||||
if (newRows.length >= overfetch) {
|
||||
return newRows;
|
||||
}
|
||||
|
||||
// READ REPLICA handle only (there is no writer/primary field on readRoute).
|
||||
const legacyRows = await scan(this.readRoute.runOpsLegacyReplica ?? this._replica);
|
||||
|
||||
// Merge under keyset order: de-dupe by id keeping the new-DB copy as
|
||||
// authoritative, re-sort in the page's direction, re-apply the over-fetch
|
||||
// window so the result matches a single union scan.
|
||||
const byId = new Map<string, WaitpointRow>();
|
||||
for (const row of newRows) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
for (const row of legacyRows) {
|
||||
if (!byId.has(row.id)) {
|
||||
byId.set(row.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) =>
|
||||
const rows = await scan();
|
||||
rows.sort((a, b) =>
|
||||
direction === "forward" ? compareIdDesc(a.id, b.id) : compareIdAsc(a.id, b.id)
|
||||
);
|
||||
|
||||
return merged.slice(0, overfetch);
|
||||
return rows.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.
|
||||
// Empty-state probe across both residencies (runStore fans out); no single runId.
|
||||
async #probeAnyToken(environmentId: string): Promise<boolean> {
|
||||
const onNew = await (this.readRoute?.runOpsNew ?? this._replica).waitpoint.findFirst({
|
||||
const found = await this.runStore.findWaitpoint({
|
||||
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);
|
||||
return Boolean(found);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import { type PrismaClientOrTransaction, type PrismaReplicaClient } from "~/db.server";
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { generateHttpCallbackUrl } from "~/services/httpCallback.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { NextRunListPresenter, type NextRunListItem } from "./NextRunListPresenter.server";
|
||||
import { waitpointStatusToApiStatus } from "./WaitpointListPresenter.server";
|
||||
@@ -22,140 +22,59 @@ export class WaitpointPresenter extends BasePresenter {
|
||||
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).
|
||||
// Forwarded to the NextRunListPresenter that hydrates connected runs; this presenter's own
|
||||
// waitpoint + connected-run reads route through `runStore`. Omitted => single-DB 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;
|
||||
}
|
||||
},
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
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,
|
||||
tags: true,
|
||||
environmentId: true,
|
||||
} as const;
|
||||
|
||||
const hydrate = (client: PrismaReplicaClient) => client.waitpoint.findFirst({ where, select });
|
||||
|
||||
if (!this.readThroughDeps) {
|
||||
return this._replica.waitpoint.findFirst({ where, select });
|
||||
}
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: friendlyId,
|
||||
environmentId,
|
||||
readNew: (client) => hydrate(client),
|
||||
readLegacy: (replica) => hydrate(replica),
|
||||
deps: {
|
||||
splitEnabled: this.readThroughDeps.splitEnabled,
|
||||
newClient:
|
||||
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ??
|
||||
(this._replica as unknown as PrismaReplicaClient),
|
||||
legacyReplica:
|
||||
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ??
|
||||
(this._replica as unknown as PrismaReplicaClient),
|
||||
// Keyed by (friendlyId, environmentId) with no classifiable waitpoint id, so the run-store
|
||||
// probes NEW then LEGACY and reads each store's own replica — resolving the waitpoint whichever
|
||||
// run store owns it. When split is off it reads the single control-plane replica (passthrough).
|
||||
return this.runStore.findWaitpoint({
|
||||
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,
|
||||
tags: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.source === "new" || result.source === "legacy-replica" ? result.value : null;
|
||||
}
|
||||
|
||||
// Connected-run friendlyIds gathered across BOTH stores. The run<->waitpoint join co-locates with
|
||||
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection; we
|
||||
// read the join on each client, resolve the run's friendlyId on that same client, and union.
|
||||
// the RUN (written on the run's DB), so the waitpoint's own store misses a cross-DB connection.
|
||||
// The run-store fans the connection lookup out to both DBs (bounded there) and resolves each run
|
||||
// id on its owning DB (by id-shape residency), so we get the union without joining across the seam.
|
||||
async #connectedRunFriendlyIds(waitpointId: string): Promise<string[]> {
|
||||
const replica = this._replica as unknown as PrismaReplicaClient;
|
||||
const rawClients: PrismaReplicaClient[] =
|
||||
this.readThroughDeps?.splitEnabled === true
|
||||
? [
|
||||
(this.readThroughDeps.newClient as PrismaReplicaClient | undefined) ?? replica,
|
||||
(this.readThroughDeps.legacyReplica as PrismaReplicaClient | undefined) ?? replica,
|
||||
]
|
||||
: [replica];
|
||||
const clients = [...new Set(rawClients)];
|
||||
|
||||
const friendlyIds = new Set<string>();
|
||||
for (const client of clients) {
|
||||
for (const friendlyId of await this.#connectedRunFriendlyIdsOn(client, waitpointId)) {
|
||||
friendlyIds.add(friendlyId);
|
||||
}
|
||||
if (friendlyIds.size >= CONNECTED_RUNS_DISPLAY_LIMIT) {
|
||||
break;
|
||||
}
|
||||
const runIds = await this.runStore.findWaitpointConnectedRunIds(waitpointId);
|
||||
if (runIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(friendlyIds).slice(0, CONNECTED_RUNS_DISPLAY_LIMIT);
|
||||
}
|
||||
|
||||
// Connected-run friendlyIds for one store, via the ORM. Two indexed reads joined in memory instead
|
||||
// of a SQL JOIN onto the (very large) TaskRun table: `id IN (...)` can only plan as a PK lookup, so
|
||||
// the planner can never scan TaskRun.
|
||||
//
|
||||
// Dedicated subset: the explicit `WaitpointRunConnection` is scalar (`taskRunId`, no FK), so a
|
||||
// connection can outlive a deleted run. We over-read connection ids, resolve runs by id (a missing
|
||||
// id just drops out -- danglers cost no display slot), and cap at the display limit.
|
||||
//
|
||||
// Control-plane full schema: no queryable join delegate (implicit M2M), so we traverse the
|
||||
// `connectedRuns` relation; it cascade-deletes with the run, so no dangler can exist.
|
||||
async #connectedRunFriendlyIdsOn(
|
||||
client: PrismaReplicaClient,
|
||||
waitpointId: string
|
||||
): Promise<string[]> {
|
||||
const dedicated = (
|
||||
client as unknown as {
|
||||
waitpointRunConnection?: {
|
||||
findMany: (args: unknown) => Promise<{ taskRunId: string }[]>;
|
||||
};
|
||||
}
|
||||
).waitpointRunConnection;
|
||||
|
||||
if (dedicated) {
|
||||
const connections = await dedicated.findMany({
|
||||
where: { waitpointId },
|
||||
select: { taskRunId: true },
|
||||
take: CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
|
||||
});
|
||||
if (connections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const runs = await client.taskRun.findMany({
|
||||
where: { id: { in: connections.map((connection) => connection.taskRunId) } },
|
||||
select: { friendlyId: true },
|
||||
take: CONNECTED_RUNS_DISPLAY_LIMIT,
|
||||
});
|
||||
return runs.map((run) => run.friendlyId);
|
||||
}
|
||||
|
||||
const waitpoint = (await (
|
||||
client.waitpoint.findFirst as (
|
||||
args: unknown
|
||||
) => Promise<{ connectedRuns: { friendlyId: string }[] } | null>
|
||||
)({
|
||||
where: { id: waitpointId },
|
||||
select: {
|
||||
connectedRuns: { select: { friendlyId: true }, take: CONNECTED_RUNS_DISPLAY_LIMIT },
|
||||
},
|
||||
})) as { connectedRuns: { friendlyId: string }[] } | null;
|
||||
return (waitpoint?.connectedRuns ?? []).map((run) => run.friendlyId);
|
||||
const runs = await this.runStore.findRuns({
|
||||
where: { id: { in: runIds } },
|
||||
select: { friendlyId: true },
|
||||
take: CONNECTED_RUNS_DISPLAY_LIMIT,
|
||||
});
|
||||
return runs.map((run) => run.friendlyId);
|
||||
}
|
||||
|
||||
public async call({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type TagListOptions = {
|
||||
@@ -14,28 +15,17 @@ const DEFAULT_PAGE_SIZE = 25;
|
||||
export type TagList = Awaited<ReturnType<WaitpointTagListPresenter["call"]>>;
|
||||
export type TagListItem = TagList["tags"][number];
|
||||
|
||||
type WaitpointTagRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type TagFindManyArgs = NonNullable<
|
||||
Parameters<PrismaClientOrTransaction["waitpointTag"]["findMany"]>[0]
|
||||
>;
|
||||
type TagQuery = {
|
||||
where: TagFindManyArgs["where"];
|
||||
orderBy: TagFindManyArgs["orderBy"];
|
||||
};
|
||||
|
||||
export class WaitpointTagListPresenter extends BasePresenter {
|
||||
constructor(
|
||||
prismaClient?: PrismaClientOrTransaction,
|
||||
replicaClient?: PrismaClientOrTransaction,
|
||||
private readonly readRoute?: {
|
||||
// Retained for source compatibility; read residency is now resolved inside `runStore`.
|
||||
_readRoute?: {
|
||||
runOpsNew?: PrismaClientOrTransaction;
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction; // READ REPLICA only — never the legacy primary
|
||||
runOpsLegacyReplica?: PrismaClientOrTransaction;
|
||||
splitEnabled?: boolean;
|
||||
}
|
||||
},
|
||||
private readonly runStore = defaultRunStore
|
||||
) {
|
||||
super(prismaClient, replicaClient);
|
||||
}
|
||||
@@ -49,15 +39,17 @@ export class WaitpointTagListPresenter extends BasePresenter {
|
||||
const hasFilters = Boolean(name?.trim());
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const query: TagQuery = {
|
||||
// Fetch one extra row to detect a following page; `runStore` fans out across the new+legacy
|
||||
// run-ops DBs and de-dupes/orders/windows the merged result itself.
|
||||
const tags = await this.runStore.findManyWaitpointTags({
|
||||
where: {
|
||||
environmentId,
|
||||
name: name ? { startsWith: name, mode: "insensitive" } : undefined,
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
};
|
||||
|
||||
const tags = await this.#scanTags(query, skip, pageSize);
|
||||
take: pageSize + 1,
|
||||
skip,
|
||||
});
|
||||
|
||||
return {
|
||||
tags: tags
|
||||
@@ -70,40 +62,4 @@ export class WaitpointTagListPresenter extends BasePresenter {
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
|
||||
async #scanTags(query: TagQuery, skip: number, pageSize: number): Promise<WaitpointTagRow[]> {
|
||||
const scan = (client: PrismaClientOrTransaction, take: number, offset: number) =>
|
||||
client.waitpointTag.findMany({ ...query, take, skip: offset });
|
||||
|
||||
if (!this.readRoute?.splitEnabled) {
|
||||
return scan(this._replica, pageSize + 1, skip);
|
||||
}
|
||||
|
||||
const prefixSize = skip + pageSize + 1;
|
||||
|
||||
const newRows = await scan(this.readRoute.runOpsNew ?? this._replica, prefixSize, 0);
|
||||
|
||||
// New DB filled the prefix => any older tags fall on a later page; skip the
|
||||
// legacy read entirely. Presence on the new DB is the migrated signal.
|
||||
if (newRows.length >= prefixSize) {
|
||||
return newRows.slice(skip, prefixSize);
|
||||
}
|
||||
|
||||
const legacyRows = await scan(
|
||||
this.readRoute.runOpsLegacyReplica ?? this._replica,
|
||||
prefixSize,
|
||||
0
|
||||
);
|
||||
|
||||
const byId = new Map<string, WaitpointTagRow>();
|
||||
for (const row of newRows) byId.set(row.id, row);
|
||||
for (const row of legacyRows) {
|
||||
if (!byId.has(row.id)) byId.set(row.id, row);
|
||||
}
|
||||
|
||||
const merged = Array.from(byId.values());
|
||||
merged.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
|
||||
|
||||
return merged.slice(skip, skip + pageSize + 1);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -51,7 +51,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
$replica,
|
||||
runOpsNewReplicaClient,
|
||||
runOpsLegacyReplica,
|
||||
runOpsLegacyReplicaClient,
|
||||
runOpsSplitReadEnabled,
|
||||
type PrismaClientOrTransaction,
|
||||
} from "~/db.server";
|
||||
@@ -98,8 +98,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const filters = BatchListFilters.parse(s);
|
||||
|
||||
const presenter = new BatchListPresenter(undefined, undefined, {
|
||||
runOpsNew: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
|
||||
runOpsLegacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
|
||||
runOpsNew: runOpsNewReplicaClient,
|
||||
runOpsLegacyReplica: runOpsLegacyReplicaClient,
|
||||
controlPlaneReplica: $replica as unknown as PrismaClientOrTransaction,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,8 @@ function createRunReplicationService(params: CreateRunReplicationServiceParams)
|
||||
|
||||
const service = new RunsReplicationService({
|
||||
clickhouseFactory,
|
||||
pgConnectionUrl: env.DATABASE_URL,
|
||||
// Legacy runs-replication source DSN; falls back to DATABASE_URL when its dedicated var is unset.
|
||||
pgConnectionUrl: env.RUN_REPLICATION_LEGACY_DATABASE_URL ?? env.DATABASE_URL,
|
||||
serviceName: name,
|
||||
slotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -31,7 +31,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const presenter = new ApiBatchResultsPresenter(undefined, undefined, {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
legacyReplica: runOpsLegacyReplica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const result = await presenter.call(batchParam, authenticationResult.environment);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
|
||||
import { $replica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const presenter = new ApiRunResultPresenter(undefined, undefined, {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
legacyReplica: runOpsLegacyReplica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const result = await presenter.call(runParam, authenticationResult.environment);
|
||||
|
||||
+8
-25
@@ -2,19 +2,13 @@ import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { type CompleteWaitpointTokenResponseBody, stringifyIO } from "@trigger.dev/core/v3";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
$replica,
|
||||
type PrismaReplicaClient,
|
||||
runOpsNewReplica,
|
||||
runOpsSplitReadEnabled,
|
||||
} from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
|
||||
import { resolveWaitpointThroughReadThrough } from "~/runEngine/concerns/resolveWaitpointThroughReadThrough.server";
|
||||
import { verifyHttpCallbackHash } from "~/services/httpCallback.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
waitpointFriendlyId: z.string(),
|
||||
@@ -39,25 +33,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const waitpointId = WaitpointId.toId(waitpointFriendlyId);
|
||||
|
||||
try {
|
||||
// Resolve wherever the waitpoint resides. The env is resolved below from the row; residency
|
||||
// is classified off the waitpoint id, so env "" is fine. Fan-out reads the run-ops replica
|
||||
// first, then the control-plane replica so both a co-located and a standalone token resolve,
|
||||
// gated on the URL-presence read gate so the fan-out spans both DBs independent of the mint flag.
|
||||
const waitpoint = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: "",
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
},
|
||||
select: { id: true, status: true, environmentId: true },
|
||||
}),
|
||||
deps: {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
// Resolve wherever the waitpoint resides. The store routes by the waitpoint id's residency
|
||||
// (id-shape) and probes both run-ops DBs, so a token on either store resolves; the env is
|
||||
// resolved below from the row via the control-plane resolver.
|
||||
const waitpoint = await runStore.findWaitpoint({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
},
|
||||
select: { id: true, status: true, environmentId: true },
|
||||
});
|
||||
|
||||
if (!waitpoint) {
|
||||
|
||||
@@ -6,18 +6,12 @@ import {
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
$replica,
|
||||
type PrismaReplicaClient,
|
||||
runOpsNewReplica,
|
||||
runOpsSplitReadEnabled,
|
||||
} from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
|
||||
import { resolveWaitpointThroughReadThrough } from "~/runEngine/concerns/resolveWaitpointThroughReadThrough.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
@@ -39,27 +33,25 @@ const { action, loader } = createActionApiRoute(
|
||||
|
||||
try {
|
||||
//check permissions
|
||||
// Resolve wherever the waitpoint resides: a standalone token lives on the control-plane
|
||||
// store, while a run-owned waitpoint co-locates with its run. Fan-out reads the run-ops
|
||||
// replica first, then the control-plane replica so both residencies resolve, gated on the
|
||||
// URL-presence read gate so the fan-out spans both DBs independent of the mint flag.
|
||||
const waitpoint = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: authentication.environment.id,
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: authentication.environment.id,
|
||||
},
|
||||
}),
|
||||
deps: {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
// The store routes by the waitpointId's residency (id shape) and probes both stores, so a
|
||||
// standalone token and a run-owned co-located waitpoint both resolve off the owning replica.
|
||||
let waitpoint = await runStore.findWaitpoint({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!waitpoint) {
|
||||
// Read-your-writes: a token completed right after mint may not have replicated yet.
|
||||
waitpoint = await runStore.findWaitpointOnPrimary({
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!waitpoint) {
|
||||
throw json({ error: "Waitpoint not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
|
||||
import { type WaitpointRetrieveTokenResponse } from "@trigger.dev/core/v3";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { z } from "zod";
|
||||
import { $replica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { runOpsLegacyReplica, runOpsNewReplica, runOpsSplitReadEnabled } from "~/db.server";
|
||||
import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const loader = createLoaderApiRoute(
|
||||
async ({ params, authentication }) => {
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
newClient: runOpsNewReplica,
|
||||
legacyReplica: $replica,
|
||||
legacyReplica: runOpsLegacyReplica,
|
||||
splitEnabled: runOpsSplitReadEnabled,
|
||||
});
|
||||
const result: WaitpointRetrieveTokenResponse = await presenter.call(
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ const { action } = createActionApiRoute(
|
||||
environmentId: authentication.environment.id,
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
// runops-routed-ok: resolveWaitpointThroughReadThrough legacy leg
|
||||
where: {
|
||||
id: waitpointId,
|
||||
environmentId: authentication.environment.id,
|
||||
|
||||
+10
-15
@@ -15,8 +15,8 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { LiveCountdown } from "~/components/runs/v3/LiveTimer";
|
||||
import { $replica, type PrismaReplicaClient } from "~/db.server";
|
||||
import { resolveWaitpointThroughReadThrough } from "~/runEngine/concerns/resolveWaitpointThroughReadThrough.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -81,19 +81,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
const waitpointId = WaitpointId.toId(waitpointFriendlyId);
|
||||
|
||||
const waitpoint = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: "",
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
select: {
|
||||
projectId: true,
|
||||
environmentId: true,
|
||||
},
|
||||
where: {
|
||||
id: waitpointId,
|
||||
},
|
||||
}),
|
||||
const waitpoint = await runStore.findWaitpoint({
|
||||
select: {
|
||||
projectId: true,
|
||||
environmentId: true,
|
||||
},
|
||||
where: {
|
||||
id: waitpointId,
|
||||
},
|
||||
});
|
||||
|
||||
if (waitpoint?.projectId !== project.id) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import {
|
||||
$replica as defaultLegacyReplica,
|
||||
runOpsLegacyReplica as defaultLegacyReplica,
|
||||
runOpsNewPrisma as defaultNewPrimary,
|
||||
runOpsNewReplica as defaultNewClient,
|
||||
runOpsSplitReadEnabled as defaultSplitReadEnabled,
|
||||
|
||||
@@ -90,6 +90,9 @@ function initializeRunsReplicationInstance() {
|
||||
const { DATABASE_URL } = process.env;
|
||||
invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set");
|
||||
|
||||
// Legacy runs-replication source DSN; falls back to DATABASE_URL when its dedicated var is unset.
|
||||
const legacyDatabaseUrl = env.RUN_REPLICATION_LEGACY_DATABASE_URL ?? DATABASE_URL;
|
||||
|
||||
if (!env.RUN_REPLICATION_CLICKHOUSE_URL) {
|
||||
console.log("🗃️ Runs replication service not enabled");
|
||||
return;
|
||||
@@ -135,7 +138,7 @@ function initializeRunsReplicationInstance() {
|
||||
// yet at module-init time, and singleton(...) memoizes this synchronous return value).
|
||||
let service = new RunsReplicationService({
|
||||
...baseReplicationOptions,
|
||||
pgConnectionUrl: DATABASE_URL,
|
||||
pgConnectionUrl: legacyDatabaseUrl,
|
||||
slotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
|
||||
// Explicit legacy source so the leader-lock key matches the id the status
|
||||
@@ -143,7 +146,7 @@ function initializeRunsReplicationInstance() {
|
||||
sources: [
|
||||
{
|
||||
id: "legacy",
|
||||
pgConnectionUrl: DATABASE_URL,
|
||||
pgConnectionUrl: legacyDatabaseUrl,
|
||||
slotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
|
||||
originGeneration: env.RUN_REPLICATION_LEGACY_ORIGIN_GENERATION,
|
||||
@@ -171,7 +174,7 @@ function initializeRunsReplicationInstance() {
|
||||
.then(async (splitEnabled) => {
|
||||
const sources = buildReplicationSources({
|
||||
splitEnabled,
|
||||
legacyUrl: DATABASE_URL,
|
||||
legacyUrl: legacyDatabaseUrl,
|
||||
newUrl: env.RUN_REPLICATION_RUN_OPS_DATABASE_URL,
|
||||
newSourceOverride: env.RUN_REPLICATION_NEW_ENABLED === "disabled" ? false : undefined,
|
||||
legacySlotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
@@ -194,7 +197,7 @@ function initializeRunsReplicationInstance() {
|
||||
// service normalizes off sources. Pass the legacy scalars to satisfy the type.
|
||||
service = new RunsReplicationService({
|
||||
...baseReplicationOptions,
|
||||
pgConnectionUrl: DATABASE_URL,
|
||||
pgConnectionUrl: legacyDatabaseUrl,
|
||||
slotName: env.RUN_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.RUN_REPLICATION_PUBLICATION_NAME,
|
||||
sources,
|
||||
|
||||
@@ -24,7 +24,8 @@ function initializeSessionsReplicationInstance() {
|
||||
|
||||
const service = new SessionsReplicationService({
|
||||
clickhouseFactory,
|
||||
pgConnectionUrl: DATABASE_URL,
|
||||
// Sessions-replication source DSN; falls back to DATABASE_URL when its dedicated var is unset.
|
||||
pgConnectionUrl: env.SESSION_REPLICATION_DATABASE_URL ?? DATABASE_URL,
|
||||
serviceName: "sessions-replication",
|
||||
slotName: env.SESSION_REPLICATION_SLOT_NAME,
|
||||
publicationName: env.SESSION_REPLICATION_PUBLICATION_NAME,
|
||||
|
||||
@@ -6,10 +6,11 @@ import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
$replica,
|
||||
prisma,
|
||||
runOpsLegacyPrisma,
|
||||
runOpsNewPrisma,
|
||||
runOpsNewReplica,
|
||||
runOpsLegacyReplica,
|
||||
runOpsNewPrismaClient,
|
||||
runOpsNewReplicaClient,
|
||||
runOpsLegacyPrismaClient,
|
||||
} from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
|
||||
@@ -1056,9 +1057,9 @@ export function setupBatchQueueCallbacks() {
|
||||
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
|
||||
await handleBatchCompletion(result, {
|
||||
splitEnabled: await splitEnabledPromise,
|
||||
newReplica: runOpsNewReplica,
|
||||
newWriter: runOpsNewPrisma,
|
||||
legacyWriter: runOpsLegacyPrisma,
|
||||
newReplica: runOpsNewReplicaClient,
|
||||
newWriter: runOpsNewPrismaClient,
|
||||
legacyWriter: runOpsLegacyPrismaClient,
|
||||
tryCompleteBatch: (batchId) => engine.tryCompleteBatch({ batchId }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* inject per-container stores/replicas, so these helpers never import db.server.
|
||||
*/
|
||||
import type { CompleteBatchResult } from "@internal/run-engine";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import type { RunStore } from "@internal/run-store";
|
||||
import type { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import type { PrismaClient, PrismaReplicaClient } from "~/db.server";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
|
||||
@@ -78,11 +79,11 @@ export async function readRunForEventOrThrow<S extends Prisma.TaskRunSelect>(
|
||||
export async function resolveBatchRunOpsWriter(
|
||||
batchId: string,
|
||||
deps: {
|
||||
newReplica: PrismaReplicaClient;
|
||||
newWriter: PrismaClient;
|
||||
legacyWriter: PrismaClient;
|
||||
newReplica: RunOpsPrismaClient;
|
||||
newWriter: RunOpsPrismaClient;
|
||||
legacyWriter: RunOpsPrismaClient;
|
||||
}
|
||||
): Promise<PrismaClient> {
|
||||
): Promise<RunOpsPrismaClient> {
|
||||
const onNew = await deps.newReplica.batchTaskRun.findFirst({
|
||||
where: { id: batchId },
|
||||
select: { id: true },
|
||||
@@ -101,9 +102,9 @@ export const QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE = "QUEUE_SIZE_LIMIT_EXCEEDED";
|
||||
|
||||
export type BatchCompletionDeps = {
|
||||
splitEnabled: boolean;
|
||||
newReplica: PrismaReplicaClient;
|
||||
newWriter: PrismaClient;
|
||||
legacyWriter: PrismaClient;
|
||||
newReplica: RunOpsPrismaClient;
|
||||
newWriter: RunOpsPrismaClient;
|
||||
legacyWriter: RunOpsPrismaClient;
|
||||
tryCompleteBatch: (batchId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
assertControlPlaneCoresidencyAdvisory,
|
||||
resolveCoresidencyEnforcement,
|
||||
} from "./controlPlaneCoresidencySentinel.server";
|
||||
import type { CoresidencyVerdict } from "./distinctDbSentinel.server";
|
||||
|
||||
const noopLog = { info: () => {}, warn: () => {} };
|
||||
|
||||
describe("resolveCoresidencyEnforcement", () => {
|
||||
// Enforcement fires ONLY when the operator opted in AND co-residency is positively "true".
|
||||
const cases: Array<{ expectSplit: boolean; coresident: CoresidencyVerdict; throws: boolean }> = [
|
||||
{ expectSplit: true, coresident: "true", throws: true },
|
||||
{ expectSplit: true, coresident: "false", throws: false },
|
||||
{ expectSplit: true, coresident: "unknown", throws: false }, // a denied probe must never fail boot
|
||||
{ expectSplit: false, coresident: "true", throws: false }, // same-DSN stage: advisory only
|
||||
{ expectSplit: false, coresident: "false", throws: false },
|
||||
{ expectSplit: false, coresident: "unknown", throws: false },
|
||||
];
|
||||
for (const c of cases) {
|
||||
it(`expectSplit=${c.expectSplit} coresident=${c.coresident} -> throw=${c.throws}`, () => {
|
||||
expect(
|
||||
resolveCoresidencyEnforcement({ expectSplit: c.expectSplit, coresident: c.coresident })
|
||||
.throw
|
||||
).toBe(c.throws);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("assertControlPlaneCoresidencyAdvisory", () => {
|
||||
const urls = { legacyUrl: "postgres://legacy", controlPlaneUrl: "postgres://cp" };
|
||||
|
||||
it("emits the probed verdict and does not throw in the same-DSN stage (expectSplit off)", async () => {
|
||||
const emit = vi.fn();
|
||||
await assertControlPlaneCoresidencyAdvisory({
|
||||
...urls,
|
||||
expectSplit: false,
|
||||
probe: async () => ({ coresident: "true" }),
|
||||
emit,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(emit).toHaveBeenCalledWith("true");
|
||||
});
|
||||
|
||||
it("throws only when enforcement is opted in AND co-residency is confirmed true", async () => {
|
||||
await expect(
|
||||
assertControlPlaneCoresidencyAdvisory({
|
||||
...urls,
|
||||
expectSplit: true,
|
||||
probe: async () => ({ coresident: "true" }),
|
||||
emit: vi.fn(),
|
||||
log: noopLog,
|
||||
})
|
||||
).rejects.toThrow(/co-resident/);
|
||||
});
|
||||
|
||||
it("never enforces on unknown even when opted in", async () => {
|
||||
const emit = vi.fn();
|
||||
await assertControlPlaneCoresidencyAdvisory({
|
||||
...urls,
|
||||
expectSplit: true,
|
||||
probe: async () => ({ coresident: "unknown", reason: "denied" }),
|
||||
emit,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(emit).toHaveBeenCalledWith("unknown");
|
||||
});
|
||||
|
||||
it("degrades a throwing probe to unknown and never crashes boot", async () => {
|
||||
const emit = vi.fn();
|
||||
const warn = vi.fn();
|
||||
await assertControlPlaneCoresidencyAdvisory({
|
||||
...urls,
|
||||
expectSplit: true,
|
||||
probe: async () => {
|
||||
throw new Error("probe blew up");
|
||||
},
|
||||
emit,
|
||||
log: { info: () => {}, warn },
|
||||
});
|
||||
expect(emit).toHaveBeenCalledWith("unknown");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops (no probe, no emit) when there is no legacy DSN", async () => {
|
||||
const emit = vi.fn();
|
||||
const probe = vi.fn();
|
||||
await assertControlPlaneCoresidencyAdvisory({
|
||||
legacyUrl: undefined,
|
||||
controlPlaneUrl: "postgres://cp",
|
||||
expectSplit: true,
|
||||
probe,
|
||||
emit,
|
||||
log: noopLog,
|
||||
});
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Advisory control-plane co-residency sentinel (Track 2, T2.3). Distinct from the HARD distinct-DB
|
||||
* interlock (legacy != new): this arm only OBSERVES whether the independent legacy run-ops DB is still
|
||||
* co-resident with the control-plane DB, emitting the `run_ops_legacy_control_plane_coresident` metric
|
||||
* + a log on every split-on boot. It NEVER fails boot on its own — same-DSN stage reads "true", cutover
|
||||
* flips it to "false", and rollback flips it back, all without blocking. Only when the operator opts in
|
||||
* via RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT (weeks after cutover, once the rollback window closes) does a
|
||||
* positively-confirmed co-residency ("true") become a boot failure. "unknown" (denied probe) never
|
||||
* enforces.
|
||||
*/
|
||||
import type { Counter } from "@opentelemetry/api";
|
||||
import { getMeter } from "@internal/tracing";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import {
|
||||
probeControlPlaneCoresidency,
|
||||
type CoresidencyProbeResult,
|
||||
type CoresidencyVerdict,
|
||||
} from "./distinctDbSentinel.server";
|
||||
|
||||
// Created lazily on first emit, NOT at module load: this module is imported by db.server before
|
||||
// tracer.server registers the global meter provider, so a module-load counter would bind to the
|
||||
// no-op provider permanently. The advisory runs from the entry-server boot path, after registration.
|
||||
let coresidentCounter: Counter | undefined;
|
||||
function getCoresidentCounter(): Counter {
|
||||
return (coresidentCounter ??= getMeter("run-ops-migration").createCounter(
|
||||
"run_ops_legacy_control_plane_coresident",
|
||||
{
|
||||
description:
|
||||
"Advisory: is the legacy run-ops DB co-resident with the control-plane DB at boot (true=same DB, false=split, unknown=probe denied)",
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export type CoresidencyEnforcement = { throw: false } | { throw: true; message: string };
|
||||
|
||||
// Pure decision: enforcement fires ONLY when the operator opted in AND co-residency was positively
|
||||
// confirmed ("true"). "unknown" never enforces (a denied probe must not fail boot); "false" is the goal.
|
||||
export function resolveCoresidencyEnforcement(args: {
|
||||
coresident: CoresidencyVerdict;
|
||||
expectSplit: boolean;
|
||||
}): CoresidencyEnforcement {
|
||||
if (args.expectSplit && args.coresident === "true") {
|
||||
return {
|
||||
throw: true,
|
||||
message:
|
||||
"RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT is on but the legacy run-ops DB is still co-resident with the control-plane DB; refusing to start.",
|
||||
};
|
||||
}
|
||||
return { throw: false };
|
||||
}
|
||||
|
||||
type AdvisoryLogger = {
|
||||
info: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export async function assertControlPlaneCoresidencyAdvisory(deps?: {
|
||||
probe?: typeof probeControlPlaneCoresidency;
|
||||
emit?: (verdict: CoresidencyVerdict) => void;
|
||||
log?: AdvisoryLogger;
|
||||
expectSplit?: boolean;
|
||||
legacyUrl?: string;
|
||||
controlPlaneUrl?: string;
|
||||
}): Promise<void> {
|
||||
const log = deps?.log ?? logger;
|
||||
const legacyUrl = deps?.legacyUrl ?? env.RUN_OPS_LEGACY_DATABASE_URL;
|
||||
const controlPlaneUrl =
|
||||
deps?.controlPlaneUrl ?? env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL;
|
||||
// No legacy DSN (single-DB / self-host) or no control-plane DSN -> nothing to compare.
|
||||
if (!legacyUrl || !controlPlaneUrl) return;
|
||||
|
||||
const probe = deps?.probe ?? probeControlPlaneCoresidency;
|
||||
const emit =
|
||||
deps?.emit ??
|
||||
((verdict: CoresidencyVerdict) => getCoresidentCounter().add(1, { result: verdict }));
|
||||
const expectSplit = deps?.expectSplit ?? env.RUN_OPS_EXPECT_CONTROL_PLANE_SPLIT;
|
||||
|
||||
let result: CoresidencyProbeResult;
|
||||
try {
|
||||
result = await probe(legacyUrl, controlPlaneUrl, { logger: log });
|
||||
} catch (error) {
|
||||
// Any unexpected throw still degrades to "unknown" — the advisory arm must never crash boot.
|
||||
log.warn("run-ops control-plane co-residency probe threw; reporting unknown", { error });
|
||||
result = { coresident: "unknown", reason: String(error) };
|
||||
}
|
||||
|
||||
emit(result.coresident);
|
||||
log.info("run_ops_legacy_control_plane_coresident", {
|
||||
coresident: result.coresident,
|
||||
reason: "reason" in result ? result.reason : undefined,
|
||||
expectSplit,
|
||||
});
|
||||
|
||||
const enforcement = resolveCoresidencyEnforcement({
|
||||
coresident: result.coresident,
|
||||
expectSplit,
|
||||
});
|
||||
if (enforcement.throw) {
|
||||
throw new Error(enforcement.message);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,48 @@ async function readDatabaseFingerprint(url: string): Promise<DatabaseFingerprint
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory co-residency verdict (Track 2, T2.3): "true" = legacy and control-plane are the SAME
|
||||
// physical DB (expected during the same-DSN stage); "false" = distinct DBs (cutover confirmation);
|
||||
// "unknown" = the fingerprint probe was denied/failed. A failed probe is ALWAYS "unknown", never
|
||||
// "false" — "false" is a positive "confirmed split" claim a failed probe cannot support.
|
||||
export type CoresidencyVerdict = "true" | "false" | "unknown";
|
||||
export type CoresidencyProbeResult =
|
||||
| { coresident: "true"; reason: string }
|
||||
| { coresident: "false" }
|
||||
| { coresident: "unknown"; reason: string };
|
||||
|
||||
// Probe whether the legacy run-ops DB is co-resident with the control-plane DB, reusing the same
|
||||
// systemIdentifier + current_database() fingerprint as the distinct-DB sentinel. Never throws.
|
||||
export async function probeControlPlaneCoresidency(
|
||||
legacyUrl: string,
|
||||
controlPlaneUrl: string,
|
||||
opts?: { logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void } }
|
||||
): Promise<CoresidencyProbeResult> {
|
||||
try {
|
||||
const [legacy, controlPlane] = await Promise.all([
|
||||
readDatabaseFingerprint(legacyUrl),
|
||||
readDatabaseFingerprint(controlPlaneUrl),
|
||||
]);
|
||||
const sameDb =
|
||||
legacy.systemIdentifier === controlPlane.systemIdentifier &&
|
||||
legacy.databaseName === controlPlane.databaseName;
|
||||
if (sameDb) {
|
||||
return {
|
||||
coresident: "true",
|
||||
reason:
|
||||
"legacy run-ops and control-plane resolve to the SAME physical database " +
|
||||
`(systemIdentifier=${legacy.systemIdentifier}, database=${legacy.databaseName})`,
|
||||
};
|
||||
}
|
||||
return { coresident: "false" };
|
||||
} catch (error) {
|
||||
// Managed PG may restrict pg_control_system(): we cannot confirm co-residency -> "unknown".
|
||||
const reason = `control-plane co-residency probe failed; reporting unknown. ${String(error)}`;
|
||||
opts?.logger?.warn(reason, { error });
|
||||
return { coresident: "unknown", reason };
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeDistinctDatabases(
|
||||
legacyUrl: string,
|
||||
newUrl: string,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Pure run-ops split READ gate. The LEGACY handle is intentionally the control-plane client,
|
||||
// so only the NEW client's distinctness gates (see runOpsSplitReadGate.test.ts).
|
||||
// Pure run-ops split READ gate. Track 2: the legacy handle is now its OWN independent client (not the
|
||||
// control-plane client), so this gate keys purely on the NEW replica being a distinct dedicated client
|
||||
// from BOTH control-plane handles — else fan-out would just re-read the control-plane DB. Keeping
|
||||
// replica reads off primaries for all three roles is markReadReplicaClient's job, not this boolean's.
|
||||
export function computeRunOpsSplitReadEnabled(args: {
|
||||
newReplica: unknown;
|
||||
controlPlaneWriter: unknown;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$comment": "Track-1 run-ops legacy guard baseline. Generated by apps/webapp/scripts/runOpsLegacyGuard.ts. Each entry is a code path that reaches a run-graph table through the control-plane Prisma client (detector i) or traverses a cross-seam relation (detector ii). This list IS the Track-1 work list; burn it down to zero. legacyAnnotations records honored `runops-legacy-ok` sites. Do NOT edit by hand — regenerate instead.",
|
||||
"regenerate": "pnpm --filter webapp run guard:runops-legacy",
|
||||
"runGraphModels": [
|
||||
"BatchTaskRun",
|
||||
"BatchTaskRunError",
|
||||
"BatchTaskRunItem",
|
||||
"Checkpoint",
|
||||
"CheckpointRestoreEvent",
|
||||
"CompletedWaitpoint",
|
||||
"TaskRun",
|
||||
"TaskRunAttempt",
|
||||
"TaskRunCheckpoint",
|
||||
"TaskRunDependency",
|
||||
"TaskRunExecutionSnapshot",
|
||||
"TaskRunTag",
|
||||
"TaskRunWaitpoint",
|
||||
"Waitpoint",
|
||||
"WaitpointRunConnection",
|
||||
"WaitpointTag"
|
||||
],
|
||||
"crossSeamRelations": [
|
||||
"BackgroundWorker.attempts",
|
||||
"BackgroundWorker.lockedRuns",
|
||||
"BackgroundWorkerTask.attempts",
|
||||
"BackgroundWorkerTask.runs",
|
||||
"BulkActionItem.destinationRun",
|
||||
"BulkActionItem.sourceRun",
|
||||
"Checkpoint.project",
|
||||
"Checkpoint.runtimeEnvironment",
|
||||
"CheckpointRestoreEvent.project",
|
||||
"CheckpointRestoreEvent.runtimeEnvironment",
|
||||
"PlaygroundConversation.run",
|
||||
"Project.CheckpointRestoreEvent",
|
||||
"Project.checkpoints",
|
||||
"Project.runTags",
|
||||
"Project.taskRunCheckpoints",
|
||||
"Project.taskRunWaitpoints",
|
||||
"Project.taskRuns",
|
||||
"Project.waitpointTags",
|
||||
"Project.waitpoints",
|
||||
"RuntimeEnvironment.CheckpointRestoreEvent",
|
||||
"RuntimeEnvironment.checkpoints",
|
||||
"RuntimeEnvironment.taskRunAttempts",
|
||||
"RuntimeEnvironment.taskRunCheckpoints",
|
||||
"RuntimeEnvironment.taskRuns",
|
||||
"RuntimeEnvironment.waitpointTags",
|
||||
"RuntimeEnvironment.waitpoints",
|
||||
"TaskQueue.attempts",
|
||||
"TaskRun.destinationBulkActionItems",
|
||||
"TaskRun.lockedBy",
|
||||
"TaskRun.lockedToVersion",
|
||||
"TaskRun.playgroundConversations",
|
||||
"TaskRun.project",
|
||||
"TaskRun.runtimeEnvironment",
|
||||
"TaskRun.sourceBulkActionItems",
|
||||
"TaskRunAttempt.backgroundWorker",
|
||||
"TaskRunAttempt.backgroundWorkerTask",
|
||||
"TaskRunAttempt.queue",
|
||||
"TaskRunAttempt.runtimeEnvironment",
|
||||
"TaskRunCheckpoint.project",
|
||||
"TaskRunCheckpoint.runtimeEnvironment",
|
||||
"TaskRunTag.project",
|
||||
"TaskRunWaitpoint.project",
|
||||
"Waitpoint.environment",
|
||||
"Waitpoint.project",
|
||||
"WaitpointTag.environment",
|
||||
"WaitpointTag.project"
|
||||
],
|
||||
"totals": {
|
||||
"violations": 0,
|
||||
"detectorI": 0,
|
||||
"detectorII": 0,
|
||||
"detectorIII": 0,
|
||||
"write": 0,
|
||||
"read": 0,
|
||||
"files": 0,
|
||||
"legacyAnnotations": 5
|
||||
},
|
||||
"violations": [],
|
||||
"legacyAnnotations": [
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 302,
|
||||
"reason": "OOM machine bump on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 360,
|
||||
"reason": "error on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 604,
|
||||
"reason": "RETRYING status on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/createTaskRunAttempt.server.ts",
|
||||
"line": 168,
|
||||
"reason": "run-bump inside legacy attempt-create tx (V1-only path)",
|
||||
"receiver": "tx"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
|
||||
"line": 92,
|
||||
"reason": "post ownerEngine()!==\"NEW\" filter",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export class BatchTriggerV3Service extends BaseService {
|
||||
span.setAttribute("batchId", batchId);
|
||||
|
||||
const dependentAttempt = body?.dependentAttempt
|
||||
? await this._prisma.taskRunAttempt.findFirst({
|
||||
? await this.runStore.findTaskRunAttempt({
|
||||
// Scope to the caller's environment (see dependentAttemptWhere).
|
||||
where: dependentAttemptWhere(body.dependentAttempt, environment.id),
|
||||
include: {
|
||||
|
||||
@@ -25,9 +25,6 @@ import parseDuration from "parse-duration";
|
||||
import { v3BulkActionPath } from "~/utils/pathBuilder";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import pMap from "p-map";
|
||||
import { type PrismaReplicaClient } from "~/db.server";
|
||||
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
|
||||
import { hydrateRunsAcrossSeam, type SeamReadDeps } from "./BulkActionV2.batchReadThrough.server";
|
||||
|
||||
export type CreateBulkActionInput = {
|
||||
organizationId: string;
|
||||
@@ -61,20 +58,6 @@ export type ProcessToCompletionResult = {
|
||||
const REPLAY_INFLIGHT_WINDOW_MS = 30 * 60 * 1000;
|
||||
|
||||
export class BulkActionService extends BaseService {
|
||||
#splitEnabledPromise?: Promise<boolean>;
|
||||
|
||||
// Resolves split mode once per service instance and returns the read-through deps for
|
||||
// bulk member hydration. Single-DB: read through the service replica (byte-identical to
|
||||
// the pre-migration read). Split: adapter defaults to run-ops new + legacy read replica.
|
||||
async #seamReadDeps(): Promise<SeamReadDeps> {
|
||||
this.#splitEnabledPromise ??= isSplitEnabled();
|
||||
const splitEnabled = await this.#splitEnabledPromise;
|
||||
return {
|
||||
splitEnabled,
|
||||
newClient: splitEnabled ? undefined : (this._replica as unknown as PrismaReplicaClient),
|
||||
};
|
||||
}
|
||||
|
||||
public async create(input: CreateBulkActionInput) {
|
||||
const { organizationId, projectId, environmentId, userId } = input;
|
||||
const filters = freezeRunListFilters(input.filters);
|
||||
@@ -325,36 +308,21 @@ export class BulkActionService extends BaseService {
|
||||
case BulkActionType.CANCEL: {
|
||||
const cancelService = new CancelTaskRunService(this._prisma);
|
||||
|
||||
const seamDeps = await this.#seamReadDeps();
|
||||
const runs = await hydrateRunsAcrossSeam({
|
||||
runIds: runIdsToProcess,
|
||||
readNew: (client, ids) =>
|
||||
client.taskRun.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
engine: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
},
|
||||
}),
|
||||
readLegacyReplica: (replica, ids) =>
|
||||
replica.taskRun.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
engine: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
},
|
||||
}),
|
||||
deps: seamDeps,
|
||||
// Route the member hydration through the run store: it reads NEW first for the whole
|
||||
// id set, then probes the legacy read replica only for the ids NEW missed that could
|
||||
// still be cuid-resident, and merges (disjoint by construction). In single-DB mode it
|
||||
// reads the collapsed store's replica, byte-identical to the pre-migration read.
|
||||
const runs = await this.runStore.findRuns({
|
||||
where: { id: { in: runIdsToProcess } },
|
||||
select: {
|
||||
id: true,
|
||||
engine: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
taskEventStore: true,
|
||||
},
|
||||
});
|
||||
|
||||
await pMap(
|
||||
@@ -391,13 +359,10 @@ export class BulkActionService extends BaseService {
|
||||
case BulkActionType.REPLAY: {
|
||||
const replayService = new ReplayTaskRunService(this._prisma);
|
||||
|
||||
const seamDeps = await this.#seamReadDeps();
|
||||
const runs = await hydrateRunsAcrossSeam({
|
||||
runIds: runIdsToProcess,
|
||||
readNew: (client, ids) => client.taskRun.findMany({ where: { id: { in: ids } } }),
|
||||
readLegacyReplica: (replica, ids) =>
|
||||
replica.taskRun.findMany({ where: { id: { in: ids } } }),
|
||||
deps: seamDeps,
|
||||
// Route the member hydration through the run store (NEW-first, legacy-replica probe for
|
||||
// the misses, disjoint merge). Full-row read: replay needs the whole TaskRun.
|
||||
const runs = await this.runStore.findRuns({
|
||||
where: { id: { in: runIdsToProcess } },
|
||||
});
|
||||
|
||||
await pMap(
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
|
||||
"start:local": "cross-env node --max-old-space-size=8192 ./build/server.js",
|
||||
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json",
|
||||
"guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts",
|
||||
"db:seed": "tsx seed.ts",
|
||||
"db:seed:ai-spans": "tsx seed-ai-spans.mts",
|
||||
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { beforeEach, describe, expect, vi } from "vitest";
|
||||
@@ -172,7 +172,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ApiRetrieveRunPresenter.findRun locked-worker version resolution", () => {
|
||||
containerTest(
|
||||
postgresTest(
|
||||
"resolves run+parent+root+children lockedToVersion with ONE grouped query, not one per id",
|
||||
async ({ prisma }) => {
|
||||
const proxied = createCallCountingProxy(prisma);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { postgresTest, heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import type { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
@@ -315,7 +315,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant)", () => {
|
||||
containerTest(
|
||||
postgresTest(
|
||||
"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,
|
||||
@@ -381,7 +381,7 @@ describe("ApiRetrieveRunPresenter.findRun store-routed read (single-DB invariant
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
postgresTest(
|
||||
"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`
|
||||
|
||||
@@ -1,27 +1,58 @@
|
||||
// Read-through proof for the public single-run result poll (ApiRunResultPresenter). The presenter
|
||||
// routes its TaskRun(+attempts) lookup-by-friendlyId through readThroughRun: split mode resolves
|
||||
// from new first then the legacy READ REPLICA on a new-probe miss (never a primary),
|
||||
// past-retention → undefined → the route's normal 404; single-DB is one plain findFirst. NEVER mock
|
||||
// the DB — the cross-version proof uses a heterogeneous legacy+new Postgres fixture; only pure
|
||||
// boundaries (splitEnabled/isPastRetention) are injected.
|
||||
// routes its TaskRun(+attempts) lookup-by-friendlyId through the run store, which selects the owning
|
||||
// DB by run-id residency (id shape): a run-ops (NEW) id reads the new store, a cuid (LEGACY) id reads
|
||||
// the legacy store; a missing run → 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 wired into a RoutingRunStore; only pure boundaries are injected.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
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.
|
||||
// runStore.server (which imports db.server defaults) does not try to connect to the env
|
||||
// database. Every read in this file goes through the RoutingRunStore we inject explicitly.
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
|
||||
|
||||
// Wire the presenter's run store to the test containers so run reads route to the container DBs
|
||||
// (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client. legacyClient may be a
|
||||
// tripwire proxy to assert a leg is never probed.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// A legacy client whose `taskRun` delegate throws if it is ever accessed — used to prove a
|
||||
// NEW-resident run is served without probing the legacy store.
|
||||
function tripwireLegacy(legacy: PrismaClient): PrismaClient {
|
||||
return new Proxy(legacy, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
throw new Error("legacy store must not be probed for a NEW-resident run");
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
// Residency by friendlyId shape (after stripping `run_`): a valid 26-char v1 body (version "1" at
|
||||
// index 25, base32hex core) → NEW; a 25-char body → LEGACY (cuid analog). ownerEngine classifies on
|
||||
// the public friendly id, so newFriendlyId uses the real generator to produce a NEW-classified body.
|
||||
@@ -182,19 +213,6 @@ async function seedRunWithAttempt(
|
||||
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",
|
||||
@@ -206,14 +224,16 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
attempt: { status: "COMPLETED", output: '"hello"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
// NEW-resident friendlyId routes to the new store; the tripwire legacy throws if its taskRun
|
||||
// delegate is ever touched, proving the legacy store is never probed.
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
prisma17 as unknown as PrismaClient,
|
||||
tripwireLegacy(prisma14 as unknown as PrismaClient)
|
||||
)
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
@@ -228,13 +248,13 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
}
|
||||
);
|
||||
|
||||
// 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.
|
||||
// Old legacy-only run resolves from the legacy store (cross-version). A LEGACY-classified id
|
||||
// routes directly to the legacy leg, which is backed by the read replica in production.
|
||||
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.
|
||||
// Seed only on legacy. New gets just an env (it is never probed for a LEGACY id).
|
||||
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, {
|
||||
@@ -243,13 +263,10 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
});
|
||||
|
||||
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,
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(legacyCtx.environmentId));
|
||||
@@ -265,23 +282,21 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
}
|
||||
);
|
||||
|
||||
// Legacy-classified id present on neither store, isPastRetention=true → past-retention → undefined.
|
||||
// A run present on neither store returns undefined — the route's normal 404 surface. (Retention
|
||||
// handling is owned by the read-through/retention layer, not this presenter; a plain miss and a
|
||||
// past-retention id are indistinguishable here, both mapping to undefined.)
|
||||
heteroPostgresTest(
|
||||
"split: a past-retention id returns undefined (the route's normal 404 surface)",
|
||||
"split: a missing id returns undefined (the route's normal 404 surface)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const friendlyId = legacyFriendlyId();
|
||||
const ctx = await fullSeed(prisma17 as unknown as PrismaClient, "past-ret-new");
|
||||
await fullSeed(prisma14 as unknown as PrismaClient, "past-ret-legacy");
|
||||
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma14 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
isPastRetention: () => true,
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
@@ -300,10 +315,12 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
attempt: { status: "COMPLETED", output: '"single"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
// No read-through deps → passthrough (single plain findFirst).
|
||||
// Single DB is the NEW leg; the run resolves there (single plain findFirst).
|
||||
const presenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma17 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
const result = await presenter.call(friendlyId, authEnv(ctx.environmentId));
|
||||
@@ -313,15 +330,15 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
expect(result.output).toBe('"single"');
|
||||
}
|
||||
|
||||
// splitEnabled:false with a throwing legacy proves no second store is touched.
|
||||
// A tripwire legacy leg proves no second store is touched for a NEW-resident run.
|
||||
const presenter2 = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: false,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
prisma17 as unknown as PrismaClient,
|
||||
tripwireLegacy(prisma14 as unknown as PrismaClient)
|
||||
)
|
||||
);
|
||||
const result2 = await presenter2.call(friendlyId, authEnv(ctx.environmentId));
|
||||
expect(result2?.ok).toBe(true);
|
||||
@@ -354,17 +371,19 @@ describe("ApiRunResultPresenter read-through (heterogeneous legacy + new Postgre
|
||||
});
|
||||
|
||||
const splitPresenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: throwingLegacy(),
|
||||
}
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
prisma17 as unknown as PrismaClient,
|
||||
tripwireLegacy(prisma14 as unknown as PrismaClient)
|
||||
)
|
||||
);
|
||||
const passthroughPresenter = new ApiRunResultPresenter(
|
||||
prisma17 as unknown as PrismaReplicaClient,
|
||||
prisma17 as unknown as PrismaReplicaClient
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma17 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
for (const id of [successId, failedId, canceledId]) {
|
||||
|
||||
@@ -24,12 +24,38 @@ vi.mock("~/db.server", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// WaitpointListPresenter reads through the module-global `runStore`; override that one wiring
|
||||
// boundary with a container-backed store (not a DB mock). Mirrors SpanPresenter.readthrough.test.ts.
|
||||
const routingStoreRef = vi.hoisted(() => ({ current: undefined as unknown }));
|
||||
vi.mock("~/v3/runStore.server", () => ({
|
||||
get runStore() {
|
||||
return routingStoreRef.current;
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import { postgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import { ApiWaitpointListPresenter } from "~/presenters/v3/ApiWaitpointListPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
// List reads fan out to both legs and dedup by id, so both legs on one container is fine.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ENV_ID = "env0000000000000000t19";
|
||||
const PROJ_ID = "proj00000000000000t19";
|
||||
|
||||
@@ -70,9 +96,10 @@ const baseEnv = {
|
||||
|
||||
describe("ApiWaitpointListPresenter read-route threading", () => {
|
||||
postgresTest(
|
||||
"split enabled: waitpoint on NEW handle is returned via readRoute",
|
||||
"split enabled: waitpoint on NEW handle is returned via the run store",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
routingStoreRef.current = makeRunStore(prisma, prisma);
|
||||
await seedLegacyParents(prisma, "t19split");
|
||||
|
||||
await prisma.waitpoint.create({
|
||||
@@ -102,9 +129,10 @@ describe("ApiWaitpointListPresenter read-route threading", () => {
|
||||
);
|
||||
|
||||
postgresTest(
|
||||
"passthrough: no readRoute => _replica only, result empty (nothing seeded)",
|
||||
"passthrough: no readRoute => run store empty, result empty (nothing seeded)",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
routingStoreRef.current = makeRunStore(prisma, prisma);
|
||||
await seedLegacyParents(prisma, "t19pass");
|
||||
|
||||
const presenter = new ApiWaitpointListPresenter(prisma, prisma);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Real heterogeneous legacy + new Postgres proof for the public waitpoint retrieve read.
|
||||
// The DB is never mocked: reads hit the two real containers. Only pure boundaries
|
||||
// (splitEnabled, isPastRetention) and recording client wrappers are
|
||||
// injected. heteroPostgresTest runs the legacy and new databases on different major versions.
|
||||
// The DB is never mocked: reads hit the two real containers. The presenter's run store is
|
||||
// wired to the test containers via makeRunStore (NEW=dedicated, LEGACY=legacy) so reads route
|
||||
// to the container DBs instead of the default localhost:5432 client. Recording client wrappers
|
||||
// let us assert which leg served the read. heteroPostgresTest runs the legacy and new databases
|
||||
// on different major versions.
|
||||
import {
|
||||
heteroPostgresTest,
|
||||
heteroRunOpsPostgresTest,
|
||||
postgresTest,
|
||||
} from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
|
||||
import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
@@ -16,6 +19,24 @@ import { ApiWaitpointPresenter } from "~/presenters/v3/ApiWaitpointPresenter.ser
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so waitpoint reads route to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client.
|
||||
// The recording handles passed in still see every findFirst the store issues through them.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// 25-char cuid body (no v1 version marker) → LEGACY residency.
|
||||
function generateLegacyCuid() {
|
||||
const suffix = Array.from(
|
||||
@@ -127,11 +148,15 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
const newClient = recording(prisma17);
|
||||
const legacy = recording(prisma14, { forbidden: true });
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
newClient.handle as unknown as PrismaClient,
|
||||
legacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
@@ -139,8 +164,9 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
expect(result.tags).toEqual(["x", "y", "z"]);
|
||||
expect(result.output).toBe(JSON.stringify({ n: 42 }));
|
||||
expect(result.type).toBe("MANUAL");
|
||||
// run-ops id → NEW: new store served the read, legacy never touched (fast-path).
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
// run-ops id → NEW: the router classifies by id-shape and serves the read from the NEW
|
||||
// store (resolve-probe + read = 2 findFirst on the NEW handle). Legacy is never touched.
|
||||
expect(newClient.calls.length).toBe(2);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
@@ -158,22 +184,28 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
});
|
||||
|
||||
const newClient = recording(prisma17);
|
||||
// The deps expose only legacyReplica — there is NO legacy-primary handle at all.
|
||||
// The legacy leg is threaded as both prisma and readOnlyPrisma — there is NO legacy-primary
|
||||
// handle distinct from the replica handle at all.
|
||||
const legacy = recording(prisma14);
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
newClient.handle as unknown as PrismaClient,
|
||||
legacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
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);
|
||||
// cuid → LEGACY: the router classifies by id-shape and routes straight to LEGACY (resolve
|
||||
// + read = 2 findFirst on the legacy handle). NEW is never probed for a legacy id.
|
||||
expect(newClient.calls.length).toBe(0);
|
||||
expect(legacy.calls.length).toBe(2);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -183,11 +215,15 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
const id = generateLegacyCuid();
|
||||
const { environment } = await seedOrgProjectEnv(prisma14, "nf");
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
});
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
recording(prisma17).handle as unknown as PrismaClient,
|
||||
recording(prisma14).handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
|
||||
"Waitpoint not found"
|
||||
@@ -196,17 +232,20 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"past-retention maps to the same not-found surface",
|
||||
"absent waitpoint maps to the same not-found surface",
|
||||
async ({ prisma17, prisma14 }) => {
|
||||
const id = generateLegacyCuid();
|
||||
const { environment } = await seedOrgProjectEnv(prisma14, "pr");
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
isPastRetention: () => true,
|
||||
});
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
recording(prisma17).handle as unknown as PrismaClient,
|
||||
recording(prisma14).handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
await expect(presenter.call(environmentArg(environment), id)).rejects.toThrow(
|
||||
"Waitpoint not found"
|
||||
@@ -225,11 +264,15 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
projectId: newEnv.project.id,
|
||||
});
|
||||
const newLegacy = recording(prisma14, { forbidden: true });
|
||||
const migratedPresenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: newLegacy.handle,
|
||||
});
|
||||
const migratedPresenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
recording(prisma17).handle as unknown as PrismaClient,
|
||||
newLegacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
const migratedResult = await migratedPresenter.call(
|
||||
environmentArg(newEnv.environment),
|
||||
newId
|
||||
@@ -244,11 +287,15 @@ describe("ApiWaitpointPresenter read-through (heterogeneous legacy + new Postgre
|
||||
id: oldEnv.environment.id,
|
||||
projectId: oldEnv.project.id,
|
||||
});
|
||||
const retentionPresenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: recording(prisma17).handle,
|
||||
legacyReplica: recording(prisma14).handle,
|
||||
});
|
||||
const retentionPresenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
recording(prisma17).handle as unknown as PrismaClient,
|
||||
recording(prisma14).handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
const retentionResult = await retentionPresenter.call(
|
||||
environmentArg(oldEnv.environment),
|
||||
oldId
|
||||
@@ -258,9 +305,9 @@ 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.
|
||||
// Regression: the NEW store is the REAL scalar-only run-ops client (prisma17). A cuid classifies
|
||||
// LEGACY, so the router routes straight to the legacy leg — the dedicated store's scalar-only
|
||||
// select never issues a relation projection that would throw PrismaClientValidationError.
|
||||
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",
|
||||
@@ -279,11 +326,15 @@ describe("ApiWaitpointPresenter read-through (dedicated scalar-only run-ops NEW
|
||||
const newClient = recording(prisma17);
|
||||
const legacy = recording(prisma14);
|
||||
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
newClient.handle as unknown as PrismaClient,
|
||||
legacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
// Must NOT throw PrismaClientValidationError; resolves the token off the legacy side.
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
@@ -291,15 +342,17 @@ describe("ApiWaitpointPresenter read-through (dedicated scalar-only run-ops NEW
|
||||
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);
|
||||
// cuid → LEGACY: routed straight to legacy (resolve + read); the scalar-only NEW client is
|
||||
// never probed for a legacy id.
|
||||
expect(newClient.calls.length).toBe(0);
|
||||
expect(legacy.calls.length).toBe(2);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("ApiWaitpointPresenter passthrough (single-DB)", () => {
|
||||
postgresTest(
|
||||
"no read-through deps → one plain replica read; legacy never touched",
|
||||
"single-DB resolves via the NEW leg; legacy leg never touched",
|
||||
async ({ prisma }) => {
|
||||
const id = generateRunOpsId();
|
||||
const { project, environment } = await seedOrgProjectEnv(prisma, "pt");
|
||||
@@ -313,20 +366,24 @@ describe("ApiWaitpointPresenter passthrough (single-DB)", () => {
|
||||
const single = recording(prisma);
|
||||
const legacy = recording(prisma, { forbidden: true });
|
||||
|
||||
// No splitEnabled → passthrough. newClient defaults to the single recording handle so we
|
||||
// can assert exactly one read against it; legacy must never fire.
|
||||
const presenter = new ApiWaitpointPresenter(undefined, undefined, {
|
||||
newClient: single.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
});
|
||||
// run-ops id → NEW leg (the single DB). Legacy leg is a tripwire and must never fire.
|
||||
const presenter = new ApiWaitpointPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
single.handle as unknown as PrismaClient,
|
||||
legacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
const result = await presenter.call(environmentArg(environment), id);
|
||||
|
||||
expect(result.id).toBe(seeded.friendlyId);
|
||||
expect(result.tags).toEqual(["one"]);
|
||||
expect(result.output).toBe(JSON.stringify({ ok: true }));
|
||||
// Passthrough: exactly one read on the single client; legacy untouched.
|
||||
expect(single.calls.length).toBe(1);
|
||||
// run-ops id → NEW leg served the read (resolve + read = 2 findFirst); legacy untouched.
|
||||
expect(single.calls.length).toBe(2);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { containerTest, heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import {
|
||||
@@ -6,10 +7,27 @@ import {
|
||||
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 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so batch reads route to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432
|
||||
// client. legacyClient may be a tripwire proxy to assert a leg is never probed.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
@@ -159,13 +177,12 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: tripwireLegacy,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
|
||||
});
|
||||
const presenter = new BatchPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma17) },
|
||||
makeRunStore(prisma17, tripwireLegacy)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
@@ -215,14 +232,13 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
|
||||
|
||||
// The structural guarantee: there is no legacy-PRIMARY handle in readThroughRun; the only
|
||||
// legacy handle threaded here is the read replica (prisma14).
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17, // NEW probe misses (nothing seeded there)
|
||||
legacyReplica: prisma14,
|
||||
// Real readThroughRun; the NEW miss falls through to the legacy replica.
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
|
||||
});
|
||||
// NEW probe misses (nothing seeded there); the router falls through to the legacy leg.
|
||||
const presenter = new BatchPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma14) },
|
||||
makeRunStore(prisma17, prisma14)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
@@ -245,13 +261,12 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedEnvironment(prisma14, "missing1");
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: prisma14,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma14),
|
||||
});
|
||||
const presenter = new BatchPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma14) },
|
||||
makeRunStore(prisma17, prisma14)
|
||||
);
|
||||
|
||||
await expect(
|
||||
presenter.call({ environmentId: ctx.environmentId, batchId: "batch_does_not_exist" })
|
||||
@@ -266,13 +281,12 @@ describe("BatchPresenter read-through (PG14 legacy + PG17 new)", () => {
|
||||
const ctx = await seedEnvironment(prisma17, "dev1", "DEVELOPMENT");
|
||||
await seedBatch(prisma17, ctx.environmentId, { friendlyId: "batch_dev1" });
|
||||
|
||||
const presenter = new BatchPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17,
|
||||
legacyReplica: prisma14,
|
||||
readThrough: readThroughRun,
|
||||
resolveDisplayableEnvironment: makeEnvResolver(prisma17),
|
||||
});
|
||||
const presenter = new BatchPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma17) },
|
||||
makeRunStore(prisma17, prisma14)
|
||||
);
|
||||
|
||||
// No userId passed -> userName resolves to the member's username (the orgMember branch).
|
||||
const result = await presenter.call({
|
||||
@@ -299,21 +313,23 @@ describe("BatchPresenter single-DB passthrough", () => {
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
// Single DB is the NEW leg; the batch resolves there so the legacy leg (tripwire) is
|
||||
// never probed.
|
||||
const tripwireLegacy = 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;
|
||||
const presenter = new BatchPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma) },
|
||||
makeRunStore(prisma, tripwireLegacy)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
@@ -342,11 +358,12 @@ describe("BatchPresenter single-DB passthrough", () => {
|
||||
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,
|
||||
{ resolveDisplayableEnvironment: makeEnvResolver(prisma) },
|
||||
makeRunStore(prisma, prisma)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
environmentId: ctx.environmentId,
|
||||
|
||||
@@ -143,6 +143,21 @@ class RoutingRunStore implements RunStore {
|
||||
pushRealtimeStream(runId: string, ...a: any[]): any {
|
||||
return (this.#resolveById(runId).pushRealtimeStream as any)(runId, ...a);
|
||||
}
|
||||
finalizeRun(runId: string, ...a: any[]): any {
|
||||
return (this.#resolveById(runId).finalizeRun as any)(runId, ...a);
|
||||
}
|
||||
findManyBatchTaskRunItems(...a: any[]): any {
|
||||
return (this.#newStore.findManyBatchTaskRunItems as any)(...a);
|
||||
}
|
||||
findBatchTaskRunItem(...a: any[]): any {
|
||||
return (this.#newStore.findBatchTaskRunItem as any)(...a);
|
||||
}
|
||||
upsertWaitpointTag(...a: any[]): any {
|
||||
return (this.#newStore.upsertWaitpointTag as any)(...a);
|
||||
}
|
||||
findManyWaitpointTags(...a: any[]): any {
|
||||
return (this.#newStore.findManyWaitpointTags as any)(...a);
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoutingStore(prisma17: PrismaClient, prisma14: PrismaClient) {
|
||||
|
||||
@@ -8,51 +8,108 @@ describe("selectRunOpsTopology (pure)", () => {
|
||||
it("split OFF: all run-ops handles collapse to control-plane and NO client is built", () => {
|
||||
const buildNewWriter = vi.fn();
|
||||
const buildNewReplica = vi.fn();
|
||||
const buildLegacyWriter = vi.fn();
|
||||
const buildLegacyReplica = vi.fn();
|
||||
const topo = selectRunOpsTopology(
|
||||
{ splitEnabled: false, legacyUrl: "postgres://a", newUrl: "postgres://b" },
|
||||
{ controlPlane: cp, buildNewWriter, buildNewReplica }
|
||||
{ controlPlane: cp, buildNewWriter, buildNewReplica, buildLegacyWriter, buildLegacyReplica }
|
||||
);
|
||||
// new run-ops collapses to the control-plane client refs (no second connection).
|
||||
// new + legacy run-ops collapse to the control-plane client refs (no second connection).
|
||||
expect(topo.newRunOps.writer).toBe(cp.writer);
|
||||
expect(topo.newRunOps.replica).toBe(cp.replica);
|
||||
expect(topo.legacyRunOps).toBe(cp);
|
||||
expect(topo.controlPlane).toBe(cp);
|
||||
expect(buildNewWriter).not.toHaveBeenCalled(); // no second connection opened
|
||||
expect(buildNewReplica).not.toHaveBeenCalled();
|
||||
expect(buildLegacyWriter).not.toHaveBeenCalled();
|
||||
expect(buildLegacyReplica).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("split ON: new-run-ops builds its own writer + replica; cp/legacy reuse cp", () => {
|
||||
it("split ON but a URL missing: still aliases legacy to control-plane and builds nothing", () => {
|
||||
const buildNewWriter = vi.fn();
|
||||
const buildLegacyWriter = vi.fn();
|
||||
const topo = selectRunOpsTopology(
|
||||
{ splitEnabled: true, newUrl: "postgres://b" }, // no legacyUrl
|
||||
{
|
||||
controlPlane: cp,
|
||||
buildNewWriter,
|
||||
buildNewReplica: vi.fn(),
|
||||
buildLegacyWriter,
|
||||
buildLegacyReplica: vi.fn(),
|
||||
}
|
||||
);
|
||||
expect(topo.legacyRunOps).toBe(cp);
|
||||
expect(topo.newRunOps.writer).toBe(cp.writer);
|
||||
expect(buildNewWriter).not.toHaveBeenCalled();
|
||||
expect(buildLegacyWriter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("split ON: legacy builds its OWN writer + replica (Track 2: no longer aliased to control-plane)", () => {
|
||||
const newWriter = { tag: "nw" } as any;
|
||||
const newReplica = { tag: "nr" } as any;
|
||||
const legacyWriter = { tag: "lw" } as any;
|
||||
const legacyReplica = { tag: "lr" } as any;
|
||||
const buildNewWriter = vi.fn().mockReturnValue(newWriter);
|
||||
const buildNewReplica = vi.fn().mockReturnValue(newReplica);
|
||||
const buildLegacyWriter = vi.fn().mockReturnValue(legacyWriter);
|
||||
const buildLegacyReplica = vi.fn().mockReturnValue(legacyReplica);
|
||||
const topo = selectRunOpsTopology(
|
||||
{
|
||||
splitEnabled: true,
|
||||
legacyUrl: "postgres://legacy",
|
||||
legacyReplicaUrl: "postgres://legacy-r",
|
||||
newUrl: "postgres://new",
|
||||
newReplicaUrl: "postgres://new-r",
|
||||
},
|
||||
{ controlPlane: cp, buildNewWriter, buildNewReplica }
|
||||
{ controlPlane: cp, buildNewWriter, buildNewReplica, buildLegacyWriter, buildLegacyReplica }
|
||||
);
|
||||
expect(topo.newRunOps.writer).toBe(newWriter);
|
||||
expect(topo.newRunOps.replica).toBe(newReplica);
|
||||
expect(topo.controlPlane).toBe(cp);
|
||||
expect(topo.legacyRunOps).toBe(cp); // legacy run-ops shares the control-plane server initially
|
||||
expect(buildNewWriter).toHaveBeenCalledTimes(1);
|
||||
// Track 2: legacy is its own independent client now, NOT the control-plane pair.
|
||||
expect(topo.legacyRunOps).not.toBe(cp);
|
||||
expect(topo.legacyRunOps.writer).toBe(legacyWriter);
|
||||
expect(topo.legacyRunOps.replica).toBe(legacyReplica);
|
||||
expect(buildLegacyWriter).toHaveBeenCalledTimes(1);
|
||||
expect(buildLegacyReplica).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("split ON without a new replica URL: replica falls back to the new writer", () => {
|
||||
it("split ON without a new replica URL: new replica falls back to the new writer", () => {
|
||||
const newWriter = { tag: "nw" } as any;
|
||||
const buildNewWriter = vi.fn().mockReturnValue(newWriter);
|
||||
const buildNewReplica = vi.fn();
|
||||
const topo = selectRunOpsTopology(
|
||||
{ splitEnabled: true, legacyUrl: "postgres://legacy", newUrl: "postgres://new" },
|
||||
{ controlPlane: cp, buildNewWriter, buildNewReplica }
|
||||
{
|
||||
controlPlane: cp,
|
||||
buildNewWriter,
|
||||
buildNewReplica,
|
||||
buildLegacyWriter: vi.fn().mockReturnValue({ tag: "lw" } as any),
|
||||
buildLegacyReplica: vi.fn(),
|
||||
}
|
||||
);
|
||||
expect(topo.newRunOps.replica).toBe(newWriter);
|
||||
expect(buildNewReplica).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("split ON without a legacy replica URL: legacy replica falls back to the legacy writer", () => {
|
||||
const legacyWriter = { tag: "lw" } as any;
|
||||
const buildLegacyWriter = vi.fn().mockReturnValue(legacyWriter);
|
||||
const buildLegacyReplica = vi.fn();
|
||||
const topo = selectRunOpsTopology(
|
||||
{ splitEnabled: true, legacyUrl: "postgres://legacy", newUrl: "postgres://new" },
|
||||
{
|
||||
controlPlane: cp,
|
||||
buildNewWriter: vi.fn().mockReturnValue({ tag: "nw" } as any),
|
||||
buildNewReplica: vi.fn(),
|
||||
buildLegacyWriter,
|
||||
buildLegacyReplica,
|
||||
}
|
||||
);
|
||||
expect(topo.legacyRunOps.writer).toBe(legacyWriter);
|
||||
expect(topo.legacyRunOps.replica).toBe(legacyWriter);
|
||||
expect(buildLegacyReplica).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRunOpsTopology (integration, real containers)", () => {
|
||||
@@ -74,9 +131,17 @@ describe("selectRunOpsTopology (integration, real containers)", () => {
|
||||
builtUrls.push(url);
|
||||
return buildReplicaClient({ url, clientType: "x" }) as any;
|
||||
},
|
||||
buildLegacyWriter: (url) => {
|
||||
builtUrls.push(url);
|
||||
return buildWriterClient({ url, clientType: "legacy" });
|
||||
},
|
||||
buildLegacyReplica: (url) => {
|
||||
builtUrls.push(url);
|
||||
return buildReplicaClient({ url, clientType: "legacy" });
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(builtUrls).toHaveLength(0); // no second connection opened
|
||||
expect(builtUrls).toHaveLength(0); // no second connection opened (legacy included)
|
||||
expect(topo.newRunOps.writer).toBe(cp.writer);
|
||||
expect(topo.newRunOps.replica).toBe(cp.replica);
|
||||
expect(topo.legacyRunOps).toBe(cp);
|
||||
@@ -87,31 +152,43 @@ describe("selectRunOpsTopology (integration, real containers)", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("split ON: constructs CP + legacy-run-ops + new-run-ops + replicas (legacy + new)", async () => {
|
||||
it("split ON: constructs CP + INDEPENDENT legacy-run-ops + new-run-ops + replicas", async () => {
|
||||
const rds = await new PostgreSqlContainer("docker.io/postgres:14").start();
|
||||
const ps = await new PostgreSqlContainer("docker.io/postgres:17").start();
|
||||
try {
|
||||
const cpWriter = buildWriterClient({ url: rds.getConnectionUri(), clientType: "cp" });
|
||||
const cp = { writer: cpWriter, replica: cpWriter };
|
||||
const topo = selectRunOpsTopology(
|
||||
{ splitEnabled: true, legacyUrl: rds.getConnectionUri(), newUrl: ps.getConnectionUri() },
|
||||
{
|
||||
splitEnabled: true,
|
||||
// Same-DSN stage: legacy points at the same physical DB as the control plane, but the
|
||||
// client is an INDEPENDENT instance (its own pool) — never the cp object.
|
||||
legacyUrl: rds.getConnectionUri(),
|
||||
legacyReplicaUrl: rds.getConnectionUri(),
|
||||
newUrl: ps.getConnectionUri(),
|
||||
},
|
||||
{
|
||||
controlPlane: cp,
|
||||
buildNewWriter: (url, ct) => buildWriterClient({ url, clientType: ct }) as any,
|
||||
buildNewReplica: (url, ct) => buildReplicaClient({ url, clientType: ct }) as any,
|
||||
buildLegacyWriter: (url, ct) => buildWriterClient({ url, clientType: ct }),
|
||||
buildLegacyReplica: (url, ct) => buildReplicaClient({ url, clientType: ct }),
|
||||
}
|
||||
);
|
||||
// CP + legacy resolve to the legacy/control-plane pair; new run-ops is the dedicated run-ops box.
|
||||
expect(topo.controlPlane).toBe(cp);
|
||||
expect(topo.legacyRunOps).toBe(cp);
|
||||
// Track 2: legacy is an independent client, never the control-plane pair/refs.
|
||||
expect(topo.legacyRunOps).not.toBe(cp);
|
||||
expect(topo.legacyRunOps.writer).not.toBe(cpWriter);
|
||||
expect(topo.newRunOps.writer).not.toBe(cpWriter);
|
||||
await topo.controlPlane.writer.$queryRawUnsafe("SELECT 1");
|
||||
await topo.legacyRunOps.writer.$queryRawUnsafe("SELECT 1");
|
||||
await topo.newRunOps.writer.$queryRawUnsafe("SELECT 1");
|
||||
const ver = await topo.newRunOps.writer.$queryRawUnsafe<Array<{ v: string }>>(
|
||||
"SELECT current_setting('server_version') AS v"
|
||||
);
|
||||
expect(ver[0].v.startsWith("17")).toBe(true); // new run-ops really is the dedicated box
|
||||
await cpWriter.$disconnect();
|
||||
await topo.legacyRunOps.writer.$disconnect();
|
||||
await topo.newRunOps.writer.$disconnect();
|
||||
} finally {
|
||||
await rds.stop();
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("selectRunOpsTopology -> computeRunOpsSplitReadEnabled (seam)", () => {
|
||||
controlPlane: cp,
|
||||
buildNewWriter: vi.fn().mockReturnValue(dedicatedNew),
|
||||
buildNewReplica: vi.fn(),
|
||||
buildLegacyWriter: vi.fn().mockReturnValue({ __tag: "legacy-writer" } as any),
|
||||
buildLegacyReplica: vi.fn(),
|
||||
}
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -43,6 +45,8 @@ describe("selectRunOpsTopology -> computeRunOpsSplitReadEnabled (seam)", () => {
|
||||
controlPlane: cp,
|
||||
buildNewWriter: vi.fn().mockReturnValue(cp.replica),
|
||||
buildNewReplica: vi.fn(),
|
||||
buildLegacyWriter: vi.fn().mockReturnValue({ __tag: "legacy-writer" } as any),
|
||||
buildLegacyReplica: vi.fn(),
|
||||
}
|
||||
);
|
||||
const warn = vi.fn();
|
||||
@@ -67,6 +71,8 @@ describe("selectRunOpsTopology -> computeRunOpsSplitReadEnabled (seam)", () => {
|
||||
controlPlane: cp,
|
||||
buildNewWriter: vi.fn(),
|
||||
buildNewReplica: vi.fn(),
|
||||
buildLegacyWriter: vi.fn(),
|
||||
buildLegacyReplica: vi.fn(),
|
||||
}
|
||||
);
|
||||
const warn = vi.fn();
|
||||
|
||||
@@ -388,8 +388,6 @@ describe("RunsReplication multi-source wiring (integration)", () => {
|
||||
|
||||
await service.start();
|
||||
|
||||
await setTimeout(3000);
|
||||
|
||||
probe = new Redis(redisOptions);
|
||||
|
||||
// Leader lock is keyed on the slot, so each source holds a distinct
|
||||
@@ -399,6 +397,33 @@ describe("RunsReplication multi-source wiring (integration)", () => {
|
||||
const newKey =
|
||||
"runs-replication:logical-replication-client:logical-replication-client:tr_new_wiring";
|
||||
|
||||
// Poll until BOTH sources have elected a leader instead of a flaky flat sleep.
|
||||
const readinessTimeoutMs = 15_000;
|
||||
const readinessPollIntervalMs = 100;
|
||||
const readinessDeadline = Date.now() + readinessTimeoutMs;
|
||||
|
||||
let legacyLocked = 0;
|
||||
let newLocked = 0;
|
||||
|
||||
while (Date.now() < readinessDeadline) {
|
||||
[legacyLocked, newLocked] = await Promise.all([
|
||||
probe.exists(legacyKey),
|
||||
probe.exists(newKey),
|
||||
]);
|
||||
|
||||
if (legacyLocked === 1 && newLocked === 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
await setTimeout(readinessPollIntervalMs);
|
||||
}
|
||||
|
||||
expect(
|
||||
legacyLocked === 1 && newLocked === 1,
|
||||
`Both leader locks should be acquired within ${readinessTimeoutMs}ms ` +
|
||||
`(legacy=${legacyLocked}, new=${newLocked})`
|
||||
).toBe(true);
|
||||
|
||||
expect(await probe.exists(legacyKey)).toBe(1);
|
||||
expect(await probe.exists(newKey)).toBe(1);
|
||||
} finally {
|
||||
|
||||
@@ -113,92 +113,55 @@ async function seedRun(client: PrismaClient, tag: string) {
|
||||
}
|
||||
|
||||
describe("RunsReplicationService (part 9/9) - per-source replication-lag attribute", () => {
|
||||
// Two named sources fanning into one flush scheduler (the production dual-fan-in shape).
|
||||
// Both point at the warm fixture Postgres via independent slots/publications, so the test
|
||||
// proves the per-source `.record(lag, { source, generation })` attribute deterministically
|
||||
// for two distinct producer identities. The cross-version (PG14<->PG17) replication boundary
|
||||
// itself is covered by part8's dual-source dedup test; here we assert the lag *attribution*,
|
||||
// which is identical regardless of the producer's Postgres version.
|
||||
replicationContainerTest(
|
||||
"tags the replication-lag histogram with each source id for a dual-source service",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const pgUrl = postgresContainer.getConnectionUri();
|
||||
// Container-free replacement for the previous dual-source variant, which polled the live
|
||||
// lag histogram against real containers until both source labels appeared (timing- and
|
||||
// label-order-flaky). It was really proving the per-source `.record(lag, { source })`
|
||||
// attribution and that the metric read/merge helpers surface every source label — asserted
|
||||
// here by recording known lag points and reading them back through those same helpers.
|
||||
test("merges recorded lag exports and surfaces every source label", async () => {
|
||||
const metricsHelper = createInMemoryMetrics();
|
||||
|
||||
const clickhouse = new ClickHouse({
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
name: "runs-replication-lag-per-source",
|
||||
logLevel: "warn",
|
||||
});
|
||||
|
||||
const metricsHelper = createInMemoryMetrics();
|
||||
let runsReplicationService: RunsReplicationService | undefined;
|
||||
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE public."TaskRun" REPLICA IDENTITY FULL;`);
|
||||
|
||||
runsReplicationService = new RunsReplicationService({
|
||||
clickhouseFactory: new TestReplicationClickhouseFactory(clickhouse),
|
||||
serviceName: "runs-replication-lag-per-source",
|
||||
redisOptions,
|
||||
sources: [
|
||||
{
|
||||
id: "legacy",
|
||||
pgConnectionUrl: pgUrl,
|
||||
slotName: "tr_lag_legacy_v1",
|
||||
publicationName: "tr_lag_legacy_v1_pub",
|
||||
originGeneration: 0,
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
pgConnectionUrl: pgUrl,
|
||||
slotName: "tr_lag_new_v1",
|
||||
publicationName: "tr_lag_new_v1_pub",
|
||||
originGeneration: 1,
|
||||
},
|
||||
],
|
||||
maxFlushConcurrency: 1,
|
||||
flushIntervalMs: 100,
|
||||
flushBatchSize: 1,
|
||||
leaderLockTimeoutMs: 5000,
|
||||
leaderLockExtendIntervalMs: 1000,
|
||||
ackIntervalSeconds: 5,
|
||||
meter: metricsHelper.meter,
|
||||
logLevel: "warn",
|
||||
});
|
||||
|
||||
await runsReplicationService.start();
|
||||
|
||||
// Each insert is decoded by BOTH slots (both subscribe to the same table), so a single
|
||||
// seed produces a lag point tagged "legacy" and one tagged "new". Poll until both land.
|
||||
const deadline = Date.now() + 40_000;
|
||||
let sources: unknown[] = [];
|
||||
let metrics = await metricsHelper.getMetrics();
|
||||
while (Date.now() < deadline) {
|
||||
const { getMetricData, getCounterAttributeValues } = makeMetricReaders(metrics);
|
||||
sources = getCounterAttributeValues(
|
||||
getMetricData("runs_replication.replication_lag_ms"),
|
||||
"source"
|
||||
);
|
||||
if (sources.includes("legacy") && sources.includes("new")) break;
|
||||
await seedRun(prisma, "lag");
|
||||
await setTimeout(500);
|
||||
metrics = await metricsHelper.getMetrics();
|
||||
try {
|
||||
const lagHistogram = metricsHelper.meter.createHistogram(
|
||||
"runs_replication.replication_lag_ms",
|
||||
{
|
||||
description: "Replication lag from Postgres commit to processing",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
const { getMetricData, histogramHasData } = makeMetricReaders(metrics);
|
||||
const replicationLag = getMetricData("runs_replication.replication_lag_ms");
|
||||
expect(replicationLag).not.toBeNull();
|
||||
expect(histogramHasData(replicationLag)).toBe(true);
|
||||
// An unrelated metric so the readers must walk past non-matching entries in the tree.
|
||||
const batchesFlushed = metricsHelper.meter.createCounter("runs_replication.batches_flushed");
|
||||
batchesFlushed.add(1, { source: "legacy" });
|
||||
|
||||
// Each source's id appears as a label value on at least one lag data point.
|
||||
expect(sources).toContain("legacy");
|
||||
expect(sources).toContain("new");
|
||||
} finally {
|
||||
await runsReplicationService?.stop();
|
||||
await metricsHelper.shutdown();
|
||||
}
|
||||
// Two producer identities fan into the same histogram; distinct attribute sets produce
|
||||
// distinct data points, one per source.
|
||||
lagHistogram.record(12, { source: "legacy", generation: 0 });
|
||||
lagHistogram.record(34, { source: "new", generation: 1 });
|
||||
|
||||
const metrics = await metricsHelper.getMetrics();
|
||||
const { getMetricData, histogramHasData, getCounterAttributeValues } =
|
||||
makeMetricReaders(metrics);
|
||||
|
||||
const replicationLag = getMetricData("runs_replication.replication_lag_ms");
|
||||
expect(replicationLag).not.toBeNull();
|
||||
|
||||
// A name that isn't present must read back as null (the readers walk the whole tree).
|
||||
expect(getMetricData("runs_replication.does_not_exist")).toBeNull();
|
||||
|
||||
expect(histogramHasData(replicationLag)).toBe(true);
|
||||
|
||||
// Every source id appears as a label value across the merged lag data points.
|
||||
const sources = getCounterAttributeValues(replicationLag, "source");
|
||||
expect(sources).toContain("legacy");
|
||||
expect(sources).toContain("new");
|
||||
|
||||
const uniqueSources = [...new Set(sources)].sort();
|
||||
expect(uniqueSources).toEqual(["legacy", "new"]);
|
||||
} finally {
|
||||
await metricsHelper.shutdown();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Single-source passthrough. When a single source is used, the lag
|
||||
// histogram records exactly one `source` label value (the source's id).
|
||||
|
||||
@@ -214,6 +214,21 @@ class RoutingRunStore implements RunStore {
|
||||
pushRealtimeStream(runId: string, streamId: string, _tx?: unknown): any {
|
||||
return this.#resolveById(runId).pushRealtimeStream(runId, streamId);
|
||||
}
|
||||
finalizeRun(runId: string, ...a: any[]): any {
|
||||
return (this.#resolveById(runId).finalizeRun as any)(runId, ...a);
|
||||
}
|
||||
findManyBatchTaskRunItems(...a: any[]): any {
|
||||
return (this.#newStore.findManyBatchTaskRunItems as any)(...a);
|
||||
}
|
||||
findBatchTaskRunItem(...a: any[]): any {
|
||||
return (this.#newStore.findBatchTaskRunItem as any)(...a);
|
||||
}
|
||||
upsertWaitpointTag(...a: any[]): any {
|
||||
return (this.#newStore.upsertWaitpointTag as any)(...a);
|
||||
}
|
||||
findManyWaitpointTags(...a: any[]): any {
|
||||
return (this.#newStore.findManyWaitpointTags as any)(...a);
|
||||
}
|
||||
}
|
||||
|
||||
function buildRoutingStore(prisma17: PrismaClient, prisma14: PrismaClient) {
|
||||
|
||||
@@ -27,7 +27,15 @@ vi.mock("~/db.server", async () => {
|
||||
if (!holder.client) {
|
||||
throw new Error(`${label} not set for this test`);
|
||||
}
|
||||
return holder.client[prop];
|
||||
const value = holder.client[prop];
|
||||
// The `runStore` singleton memoizes each Prisma delegate on first access, pinning it to
|
||||
// the first test's (later-dropped) DB. Re-resolve so it routes to the current client.
|
||||
if (value !== null && typeof value === "object") {
|
||||
return new Proxy(value, {
|
||||
get: (_d, method) => holder.client[prop][method],
|
||||
});
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@internal/testcontainers";
|
||||
import { Prisma, type PrismaClient, type WaitpointStatus } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import {
|
||||
WaitpointListPresenter,
|
||||
type WaitpointListOptions,
|
||||
@@ -36,6 +37,25 @@ import {
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so waitpoint reads route to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client.
|
||||
// In single-DB passthrough both legs point at the same client (a no-op fan-out that de-dupes
|
||||
// back to one DB's rows), mirroring production single-DB deployments.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient): RoutingRunStore {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
@@ -175,17 +195,14 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
// 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);
|
||||
// Single-DB deployment: both run-store legs point at the same container client, so the
|
||||
// mandatory NEW+LEGACY fan-out in findManyWaitpoints reads one DB and de-dupes by id.
|
||||
const presenter = new WaitpointListPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
undefined,
|
||||
makeRunStore(prisma, prisma)
|
||||
);
|
||||
const result = await presenter.call(baseOptions(ctx.environmentId, { pageSize: 2 }));
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -208,13 +225,9 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
"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);
|
||||
// FLAG: dropped the old "throwing legacy handle never invoked" tripwire — RoutingRunStore's
|
||||
// findManyWaitpoints always fans out to both legs, so single-DB is modeled by both legs
|
||||
// sharing one client (above), not by a throwing legacy leg.
|
||||
});
|
||||
|
||||
// Raw paginated scan byte-identical + identical ORDER-BY across PG14/PG17.
|
||||
@@ -282,7 +295,12 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
}
|
||||
|
||||
// Same with a cursor active (forward => id < cursor) — exercised via the presenter.
|
||||
const presenter14 = new WaitpointListPresenter(prisma14, prisma14);
|
||||
const presenter14 = new WaitpointListPresenter(
|
||||
prisma14,
|
||||
prisma14,
|
||||
undefined,
|
||||
makeRunStore(prisma14, prisma14)
|
||||
);
|
||||
const cursored = await presenter14.call(
|
||||
baseOptions(ctx14.environmentId, { pageSize: 2, cursor: "wp10000000000000000000004" })
|
||||
);
|
||||
@@ -343,13 +361,14 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
},
|
||||
}) as PrismaClient;
|
||||
|
||||
const presenter = new WaitpointListPresenter(prisma17, prisma17, {
|
||||
runOpsNew: prisma17,
|
||||
runOpsLegacyReplica: legacyCounted,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const presenter = new WaitpointListPresenter(
|
||||
prisma17,
|
||||
prisma17,
|
||||
undefined,
|
||||
makeRunStore(prisma17, legacyCounted)
|
||||
);
|
||||
|
||||
// pageSize 4 < union of 5 => new DB (2 rows) does NOT fill page+1=5, so legacy is scanned.
|
||||
// pageSize 4 < union of 5 => merged NEW (2 rows) + LEGACY page fills 4.
|
||||
const result = await presenter.call(baseOptions(ctx17.environmentId, { pageSize: 4 }));
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
@@ -371,20 +390,23 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
expect(dupes).toHaveLength(1);
|
||||
expect(dupes[0]?.status).toBe("COMPLETED");
|
||||
|
||||
// Now a page the new DB fully satisfies => legacy must NOT be scanned.
|
||||
// A page the new DB fully satisfies still returns only the newest token.
|
||||
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 presenter2 = new WaitpointListPresenter(
|
||||
prisma17,
|
||||
prisma17,
|
||||
undefined,
|
||||
makeRunStore(prisma17, legacyCounted)
|
||||
);
|
||||
// pageSize 1 => page+1 = 2; new DB has 2 rows, so its keyset window already covers the page.
|
||||
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);
|
||||
// FLAG: dropped the old "legacy NOT scanned when new fills the page" tripwire.
|
||||
// RoutingRunStore.findManyWaitpoints unconditionally fans out to both legs, so legacy is
|
||||
// always scanned; the result (only the newest token) is what proves the window is correct.
|
||||
}
|
||||
);
|
||||
|
||||
@@ -401,11 +423,12 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
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 splitPresenter = new WaitpointListPresenter(
|
||||
prisma17,
|
||||
prisma17,
|
||||
undefined,
|
||||
makeRunStore(prisma17, prisma14)
|
||||
);
|
||||
const r1 = await splitPresenter.call(
|
||||
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
|
||||
);
|
||||
@@ -426,18 +449,14 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
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,
|
||||
});
|
||||
// Single-DB passthrough: both legs share one client; the probe (findWaitpoint) tries NEW
|
||||
// then LEGACY, both empty here, so no false-positive.
|
||||
const passthroughPresenter = new WaitpointListPresenter(
|
||||
prisma17,
|
||||
prisma17,
|
||||
undefined,
|
||||
makeRunStore(prisma17, prisma17)
|
||||
);
|
||||
const r3 = await passthroughPresenter.call(
|
||||
baseOptions(ctx.environmentId, { idempotencyKey: "no-such-key" })
|
||||
);
|
||||
@@ -474,11 +493,12 @@ describe("WaitpointListPresenter read-route", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new WaitpointListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const presenter = new WaitpointListPresenter(
|
||||
prisma14 as any,
|
||||
prisma14 as any,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as any, prisma14 as any)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
environment: {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// RED->GREEN guard: WaitpointPresenter connected-run gather must BOUND the fetch of a waitpoint's
|
||||
// connected-run ids, not just the number displayed. A "displayed count <= 5" assertion would
|
||||
// false-green (the take:5 on the run resolve already bounds the DISPLAY). Instead this captures the
|
||||
// real `taskRun.findMany` call args via a Proxy over a REAL testcontainer Postgres client (no mocks)
|
||||
// and asserts the IN-list is capped at the scan limit (danglers over-read), never unbounded.
|
||||
//
|
||||
// Exercises the dedicated-schema (Prisma `waitpointRunConnection`) branch: waitpoint + more than the
|
||||
// scan-limit connected runs seeded on the NEW dedicated run-ops client (RunOpsPrismaClient, prisma17).
|
||||
// The connected-run gather now delegates to the injectable run store (findWaitpointConnectedRunIds +
|
||||
// findRuns take:CONNECTED_RUNS_DISPLAY_LIMIT), which fans out over both DBs via $queryRaw and is
|
||||
// BOUNDED inside the store — so the old per-client `taskRun.findMany` IN-list scan-limit assertion no
|
||||
// longer describes the code (that store-level bound is covered by
|
||||
// internal-packages/run-store/src/PostgresRunStore.connectedRunsBounded.test.ts). This test asserts
|
||||
// the OUTPUT bound instead: seeding far more connections than the display limit, the presenter still
|
||||
// returns at most CONNECTED_RUNS_DISPLAY_LIMIT connected-run friendlyIds. Exercises the dedicated
|
||||
// `waitpointRunConnection` branch on the NEW run-ops client (prisma17), routed through a store wired
|
||||
// to the per-test containers (NEW=dedicated prisma17, LEGACY=prisma14).
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
const legacyReplicaHolder = vi.hoisted(() => ({ client: undefined as any }));
|
||||
@@ -62,15 +63,34 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
CONNECTED_RUNS_CONNECTION_SCAN_LIMIT,
|
||||
CONNECTED_RUNS_DISPLAY_LIMIT,
|
||||
WaitpointPresenter,
|
||||
} from "~/presenters/v3/WaitpointPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers (NEW=dedicated prisma17, LEGACY=prisma14) so
|
||||
// the connected-run gather routes to the containers instead of the default localhost:5432 store.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
@@ -153,43 +173,14 @@ async function seedRun(
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap the REAL dedicated run-ops client's `taskRun.findMany` to capture the args of every call,
|
||||
// delegating unchanged to the real client (the DB still runs the query -- pure instrumentation,
|
||||
// never a mock).
|
||||
function capturingTaskRunFindMany(real: RunOpsPrismaClient): {
|
||||
client: RunOpsPrismaClient;
|
||||
calls: { where?: { id?: { in?: string[] } } }[];
|
||||
} {
|
||||
const calls: { where?: { id?: { in?: string[] } } }[] = [];
|
||||
const wrappedTaskRun = new Proxy((real as any).taskRun, {
|
||||
get(target, prop) {
|
||||
if (prop === "findMany") {
|
||||
return (...args: any[]) => {
|
||||
calls.push(args[0]);
|
||||
return (target as any)[prop](...args);
|
||||
};
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
});
|
||||
const client = new Proxy(real as object, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return wrappedTaskRun;
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as RunOpsPrismaClient;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
// Seed MORE than the scan limit so the assertion bites: an unbounded gather IN-lists all of them,
|
||||
// a correctly bounded one caps at CONNECTED_RUNS_CONNECTION_SCAN_LIMIT.
|
||||
// Seed MORE than the scan limit (well above the display limit) so the output bound bites: an
|
||||
// unbounded gather would surface every connection, a correctly bounded one caps the returned
|
||||
// connected-run friendlyIds at CONNECTED_RUNS_DISPLAY_LIMIT.
|
||||
const CONNECTED_RUN_COUNT = CONNECTED_RUNS_CONNECTION_SCAN_LIMIT + 5;
|
||||
|
||||
describe("WaitpointPresenter bounds the connected-run-id FETCH", () => {
|
||||
describe("WaitpointPresenter bounds the connected runs it returns", () => {
|
||||
heteroRunOpsPostgresTest(
|
||||
"a waitpoint with more connections than the scan limit caps the IN-list at the scan limit",
|
||||
"a waitpoint with many more connections than the display limit returns at most the display limit",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const ctx = await seedParents(prisma14, "bounded");
|
||||
const waitpoint = await seedWaitpoint(prisma17, ctx, "waitpoint_bounded");
|
||||
@@ -206,30 +197,37 @@ describe("WaitpointPresenter bounds the connected-run-id FETCH", () => {
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const { client: countingPrisma17, calls } = capturingTaskRunFindMany(prisma17);
|
||||
// Route the gather through a store wired to the containers; without this it would fall through
|
||||
// to the default global store (localhost:5432) and fail in CI. NEW=prisma17 (dedicated) owns
|
||||
// the waitpoint + connections + runs; LEGACY=prisma14 holds the env parents.
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: countingPrisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
|
||||
await presenter.call({
|
||||
const result = await presenter.call({
|
||||
friendlyId: waitpoint.friendlyId,
|
||||
environmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
});
|
||||
|
||||
// The guard: assert the FETCH (the IN-list built from the connected-run-id gather) is
|
||||
// bounded, not just the eventual displayed count. An unbounded gather would IN-list every
|
||||
// connection row; the dedicated branch over-reads up to CONNECTED_RUNS_CONNECTION_SCAN_LIMIT
|
||||
// (25) so a display slot is never lost to a dangler, so the IN-list must be capped at the
|
||||
// scan limit -- above the display limit (5), but never unbounded.
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const call of calls) {
|
||||
expect(call.where?.id?.in?.length ?? 0).toBeLessThanOrEqual(
|
||||
CONNECTED_RUNS_CONNECTION_SCAN_LIMIT
|
||||
);
|
||||
// OUTPUT bound: the waitpoint has CONNECTED_RUN_COUNT (> scan limit) connections, but the
|
||||
// presenter surfaces at most CONNECTED_RUNS_DISPLAY_LIMIT connected-run friendlyIds. The
|
||||
// NextRunListPresenter mock echoes the gathered runId set back verbatim, so
|
||||
// `result.connectedRuns` is exactly the (bounded) gather output. An unbounded gather would
|
||||
// return every connection here. The IN-list scan-limit bound now lives in the run store and is
|
||||
// covered by internal-packages/run-store/src/PostgresRunStore.connectedRunsBounded.test.ts.
|
||||
expect(result?.connectedRuns.length).toBe(CONNECTED_RUNS_DISPLAY_LIMIT);
|
||||
const friendlyIds = result?.connectedRuns.map((r) => r.friendlyId) ?? [];
|
||||
expect(new Set(friendlyIds).size).toBe(CONNECTED_RUNS_DISPLAY_LIMIT);
|
||||
for (const friendlyId of friendlyIds) {
|
||||
expect(friendlyId.startsWith("run_b")).toBe(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
@@ -66,6 +67,23 @@ import {
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers (NEW=dedicated prisma17, LEGACY=prisma14) so
|
||||
// the connected-run gather routes to the containers instead of the default localhost:5432 store.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
@@ -174,11 +192,16 @@ describe("WaitpointPresenter#connectedRunIdsOn is robust to dangling (FK-free) c
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14 as unknown as PrismaClient)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
friendlyId: waitpoint.friendlyId,
|
||||
|
||||
@@ -58,12 +58,31 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers. The NEW leg is the REAL dedicated run-ops
|
||||
// client (subset schema, explicit `waitpointRunConnection` join), so `schemaVariant: "dedicated"`
|
||||
// is required for the connected-run gather to resolve the join model rather than a missing relation.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
@@ -168,11 +187,16 @@ describe("WaitpointPresenter against the REAL dedicated run-ops client", () => {
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
@@ -200,11 +224,16 @@ describe("WaitpointPresenter against the REAL dedicated run-ops client", () => {
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, waitpoint.friendlyId));
|
||||
|
||||
@@ -240,11 +269,16 @@ describe("WaitpointPresenter against the REAL dedicated run-ops client", () => {
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, waitpoint.friendlyId));
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
heteroPostgresTest,
|
||||
replicationContainerTest,
|
||||
} from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient, WaitpointType } from "@trigger.dev/database";
|
||||
import { PrismaClient as PrismaClientCtor } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
@@ -68,6 +69,25 @@ import { setupClickhouseReplication } from "./utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so waitpoint reads route to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client.
|
||||
// The NEW leg is the read-through preferred store; find* methods probe NEW then LEGACY and some
|
||||
// collection reads (connected-run gather) fan out to BOTH legs.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// A read client whose waitpoint.findFirst calls are recorded, split by kind. The presenter issues
|
||||
// two shapes of waitpoint.findFirst: a HYDRATE (loads the waitpoint scalar row -- no `connectedRuns`
|
||||
// in its select) and a connectedRuns-RELATION read (control-plane branch of the connected-runs
|
||||
@@ -262,21 +282,28 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec
|
||||
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 presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: newClient.handle,
|
||||
legacyReplica: legacy.handle,
|
||||
},
|
||||
makeRunStore(
|
||||
newClient.handle as unknown as PrismaClient,
|
||||
legacy.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
// New-first short-circuit: the HYDRATE answered from new and never fell through to a legacy
|
||||
// hydrate (the throwing handle proves it). The connectedRuns cross-DB union still reads legacy
|
||||
// legitimately -- that read is allowed and must NOT count as a hydrate.
|
||||
expect(newClient.hydrateCalls.length).toBe(1);
|
||||
expect(legacy.hydrateCalls.length).toBe(0);
|
||||
expect(legacy.connectedRunsCalls.length).toBeGreaterThan(0);
|
||||
// New-first short-circuit: the waitpoint lookup resolves on NEW and never falls through to
|
||||
// legacy's waitpoint.findFirst (the throwing handle proves it). The connected-run gather does
|
||||
// fan out to both legs, but via `$queryRaw`, so the waitpoint.findFirst tripwire stays clean.
|
||||
expect(newClient.calls.length).toBe(1);
|
||||
expect(legacy.calls.length).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -292,25 +319,30 @@ describe("WaitpointPresenter dual-DB read-through (hetero PG14 + PG17, no connec
|
||||
});
|
||||
|
||||
const single = recording(prisma14);
|
||||
const second = recording(prisma17, { forbidden: true });
|
||||
// Control-plane reads (env resolution) still flow through the mocked db.server $replica proxy.
|
||||
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();
|
||||
// Single-DB passthrough: there is no separate split leg to skip under the run store, so model
|
||||
// it as one store backing BOTH legs (new === legacy === the single client). Injected explicitly
|
||||
// rather than via the global singleton so the read path is deterministic across the full suite.
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
makeRunStore(
|
||||
single.handle as unknown as PrismaClient,
|
||||
single.handle as unknown as PrismaClient
|
||||
)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
expect(result?.id).toBe(seeded.friendlyId);
|
||||
expect(result?.tags).toEqual(["one"]);
|
||||
// Two reads on the single client, both on the one handle: the first findFirst hydrates the
|
||||
// waitpoint, the second loads the `connectedRuns` relation (the implicit M2M has no queryable
|
||||
// join delegate, so the ORM must traverse the relation with a second findFirst). The second
|
||||
// handle is never touched -- passthrough is structural, the split branch never fires.
|
||||
expect(single.calls.length).toBe(2);
|
||||
expect(second.calls.length).toBe(0);
|
||||
// The waitpoint is hydrated against the single client. The connected-runs gather's exact read
|
||||
// shape is a run-store detail (covered by the run-store tests), so assert the waitpoint was read
|
||||
// here, not an exact call count.
|
||||
expect(single.calls.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -374,11 +406,16 @@ describe("WaitpointPresenter connected-runs hydrate routed through read-through
|
||||
// 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 presenter = new WaitpointPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
makeRunStore(prismaNew, prisma)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
@@ -412,8 +449,14 @@ describe("WaitpointPresenter bare-ctor production default activates readThroughR
|
||||
newClientHolder.client = prisma17;
|
||||
legacyReplicaHolder.client = prisma17;
|
||||
|
||||
// No newClient/legacyReplica injected — production ctor shape.
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, { splitEnabled: true });
|
||||
// Production readThroughDeps shape (no newClient/legacyReplica), but the run store is wired to
|
||||
// the per-test container instead of the global singleton (which caches across files on globalThis).
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{ splitEnabled: true },
|
||||
makeRunStore(prisma17, prisma17)
|
||||
);
|
||||
|
||||
const result = await presenter.call(callArgs(ctx, seeded.friendlyId));
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ vi.mock("~/presenters/v3/NextRunListPresenter.server", () => ({
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
@@ -70,6 +71,24 @@ import {
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so the connected-run gather routes to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client. The
|
||||
// gather's findWaitpointConnectedRunIds fans out to BOTH legs and unions, keeping the split intent.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient) {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
@@ -176,11 +195,16 @@ describe("WaitpointPresenter — connected runs SPLIT across both physical DBs",
|
||||
legacyReplicaHolder.client = prisma14;
|
||||
newClientHolder.client = prisma17;
|
||||
|
||||
const presenter = new WaitpointPresenter(undefined, undefined, {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
});
|
||||
const presenter = new WaitpointPresenter(
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaClient,
|
||||
legacyReplica: prisma14,
|
||||
},
|
||||
makeRunStore(prisma17 as unknown as PrismaClient, prisma14)
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
friendlyId: waitpoint.friendlyId,
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("~/db.server", () => ({
|
||||
}));
|
||||
|
||||
import { heteroRunOpsPostgresTest, postgresTest } from "@internal/testcontainers";
|
||||
import { PostgresRunStore, RoutingRunStore } from "@internal/run-store";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import {
|
||||
@@ -24,6 +25,25 @@ import {
|
||||
|
||||
vi.setConfig({ testTimeout: 120_000 });
|
||||
|
||||
// Wire the presenter's run store to the test containers so waitpoint-tag reads route to the
|
||||
// container DBs (NEW=dedicated, LEGACY=legacy) instead of the default localhost:5432 client.
|
||||
// In single-DB passthrough both legs point at the same client (a no-op fan-out that de-dupes
|
||||
// back to one DB's rows), mirroring production single-DB deployments.
|
||||
function makeRunStore(newClient: PrismaClient, legacyClient: PrismaClient): RoutingRunStore {
|
||||
return new RoutingRunStore({
|
||||
new: new PostgresRunStore({
|
||||
prisma: newClient as never,
|
||||
readOnlyPrisma: newClient as never,
|
||||
schemaVariant: "dedicated",
|
||||
}),
|
||||
legacy: new PostgresRunStore({
|
||||
prisma: legacyClient as never,
|
||||
readOnlyPrisma: legacyClient as never,
|
||||
schemaVariant: "legacy",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
type LegacySeedContext = {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
@@ -95,7 +115,7 @@ function opts(environmentId: string, overrides: Partial<TagListOptions> = {}): T
|
||||
|
||||
describe("WaitpointTagListPresenter read-route", () => {
|
||||
postgresTest(
|
||||
"passthrough: no readRoute => _replica only, legacy handle never touched",
|
||||
"passthrough: single-DB read returns the env's tags ordered id desc",
|
||||
async ({ prisma }) => {
|
||||
setDbClient(prisma);
|
||||
const ctx = await seedLegacyParents(prisma, "pass");
|
||||
@@ -123,18 +143,14 @@ describe("WaitpointTagListPresenter read-route", () => {
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
// Single-DB deployment: both run-store legs point at the same container client, so the
|
||||
// mandatory NEW+LEGACY fan-out in findManyWaitpointTags reads one DB and de-dupes by id.
|
||||
const presenter = new WaitpointTagListPresenter(
|
||||
prisma,
|
||||
prisma,
|
||||
undefined,
|
||||
makeRunStore(prisma, prisma)
|
||||
);
|
||||
const result = await presenter.call(opts(ctx.environmentId, { pageSize: 10 }));
|
||||
|
||||
expect(result.tags.map((t) => t.name)).toEqual(["gamma", "beta", "alpha"]);
|
||||
@@ -179,11 +195,12 @@ describe("WaitpointTagListPresenter read-route", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const presenter = new WaitpointTagListPresenter(
|
||||
prisma14 as any,
|
||||
prisma14 as any,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as any, prisma14 as any)
|
||||
);
|
||||
|
||||
const result = await presenter.call(opts(envId, { pageSize: 4 }));
|
||||
|
||||
@@ -221,11 +238,12 @@ describe("WaitpointTagListPresenter read-route", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const presenter = new WaitpointTagListPresenter(prisma14 as any, prisma14 as any, {
|
||||
runOpsNew: prisma17 as any,
|
||||
runOpsLegacyReplica: prisma14 as any,
|
||||
splitEnabled: true,
|
||||
});
|
||||
const presenter = new WaitpointTagListPresenter(
|
||||
prisma14 as any,
|
||||
prisma14 as any,
|
||||
undefined,
|
||||
makeRunStore(prisma17 as any, prisma14 as any)
|
||||
);
|
||||
|
||||
const page2 = await presenter.call(opts(envId, { pageSize: 2, page: 2 }));
|
||||
expect(page2.tags.map((t) => t.name)).toEqual(["d", "c"]);
|
||||
|
||||
@@ -27,6 +27,22 @@ else
|
||||
echo "RUN_OPS_DATABASE_URL not set, skipping run-ops migrations."
|
||||
fi
|
||||
|
||||
# Run-ops split: keep the legacy runs DB's schema current by applying the full @trigger.dev/database
|
||||
# migrations to it too, pointed at its direct (non-pooled) URL. Only runs when that URL is configured;
|
||||
# installs that never set it skip this entirely.
|
||||
if [ -n "$RUN_OPS_LEGACY_DIRECT_URL" ]; then
|
||||
if [ "$SKIP_RUN_OPS_LEGACY_MIGRATIONS" != "1" ]; then
|
||||
echo "Running legacy run-ops migrations"
|
||||
# Subshell with tracing off so `set -x` does not print the DSN (with credentials) to the logs.
|
||||
(set +x; DATABASE_URL="$RUN_OPS_LEGACY_DIRECT_URL" DIRECT_URL="$RUN_OPS_LEGACY_DIRECT_URL" pnpm --filter @trigger.dev/database db:migrate:deploy)
|
||||
echo "Legacy run-ops migrations done"
|
||||
else
|
||||
echo "SKIP_RUN_OPS_LEGACY_MIGRATIONS=1, skipping legacy run-ops migrations."
|
||||
fi
|
||||
else
|
||||
echo "RUN_OPS_LEGACY_DIRECT_URL not set, skipping legacy run-ops migrations."
|
||||
fi
|
||||
|
||||
if [ "$SKIP_DASHBOARD_AGENT_MIGRATIONS" != "1" ]; then
|
||||
echo "Running dashboard agent migrations"
|
||||
pnpm --filter @internal/dashboard-agent-db db:migrate:deploy
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- Run-graph tables are queried via dedicated clients; the remaining cross-graph foreign key
|
||||
-- constraints between control-plane tables and run-graph tables are removed so neither side
|
||||
-- needs the other's tables present to enforce them. Referential integrity is app-enforced,
|
||||
-- matching the sibling FK removals. IF EXISTS keeps this idempotent across databases.
|
||||
|
||||
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
|
||||
SET lock_timeout = '5s';
|
||||
|
||||
-- BulkActionItem
|
||||
ALTER TABLE "BulkActionItem" DROP CONSTRAINT IF EXISTS "BulkActionItem_sourceRunId_fkey";
|
||||
ALTER TABLE "BulkActionItem" DROP CONSTRAINT IF EXISTS "BulkActionItem_destinationRunId_fkey";
|
||||
|
||||
-- PlaygroundConversation
|
||||
ALTER TABLE "PlaygroundConversation" DROP CONSTRAINT IF EXISTS "PlaygroundConversation_runId_fkey";
|
||||
|
||||
-- Checkpoint
|
||||
ALTER TABLE "Checkpoint" DROP CONSTRAINT IF EXISTS "Checkpoint_projectId_fkey";
|
||||
ALTER TABLE "Checkpoint" DROP CONSTRAINT IF EXISTS "Checkpoint_runtimeEnvironmentId_fkey";
|
||||
|
||||
-- CheckpointRestoreEvent
|
||||
ALTER TABLE "CheckpointRestoreEvent" DROP CONSTRAINT IF EXISTS "CheckpointRestoreEvent_projectId_fkey";
|
||||
ALTER TABLE "CheckpointRestoreEvent" DROP CONSTRAINT IF EXISTS "CheckpointRestoreEvent_runtimeEnvironmentId_fkey";
|
||||
|
||||
-- TaskRunAttempt
|
||||
ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_queueId_fkey";
|
||||
@@ -60,8 +60,8 @@ export class WaitpointSystem {
|
||||
tx?: PrismaClientOrTransaction;
|
||||
}) {
|
||||
// Route the delete: a run's edges may live on #new and/or #legacy (mid-drain), so it must fan
|
||||
// across both stores. The caller's control-plane tx would only clear #legacy, orphaning #new
|
||||
// edges that then re-block the run after a retry. The router applies tx to the #legacy leg only.
|
||||
// across both stores. The caller's `tx` is not forwarded into either leg — each store's delete
|
||||
// runs on its own client (the router never threads a control-plane tx into a routed write).
|
||||
const deleted = await this.$.runStore.deleteManyTaskRunWaitpoints(
|
||||
{ where: { taskRunId: runId } },
|
||||
tx
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PrismaClientOrTransaction,
|
||||
TaskRun,
|
||||
TaskRunStatus,
|
||||
WaitpointTag,
|
||||
} from "@trigger.dev/database";
|
||||
import type {
|
||||
ClearIdempotencyKeyInput,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
CreateFailedRunInput,
|
||||
CreateRunInput,
|
||||
ExpireSnapshotInput,
|
||||
FinalizeRunData,
|
||||
ForWaitpointCompletionContext,
|
||||
LockRunData,
|
||||
ReadClient,
|
||||
@@ -65,7 +67,9 @@ export interface RunOpsCapableClient {
|
||||
// optional so the legacy client stays assignable. Touched only on the dedicated branch.
|
||||
waitpointRunConnection?: RunOpsDelegate<"createMany" | "findMany">;
|
||||
batchTaskRun: RunOpsDelegate<"create" | "findFirst" | "update" | "updateMany">;
|
||||
batchTaskRunItem: RunOpsDelegate<"create" | "count" | "updateMany">;
|
||||
batchTaskRunItem: RunOpsDelegate<"create" | "count" | "updateMany" | "findFirst" | "findMany">;
|
||||
// Standalone entity keyed by (environmentId, name); present on both schemas.
|
||||
waitpointTag: RunOpsDelegate<"upsert" | "findMany">;
|
||||
$queryRaw: PrismaClient["$queryRaw"];
|
||||
$executeRaw: PrismaClient["$executeRaw"];
|
||||
}
|
||||
@@ -515,6 +519,7 @@ const RUN_OPS_DELEGATE_KEYS: ReadonlySet<string> = new Set([
|
||||
"waitpointRunConnection",
|
||||
"batchTaskRun",
|
||||
"batchTaskRunItem",
|
||||
"waitpointTag",
|
||||
]);
|
||||
|
||||
// Every method call on a delegate rewrites ONLY its rejection reason; success is untouched.
|
||||
@@ -978,6 +983,61 @@ export class PostgresRunStore implements RunStore {
|
||||
) as Promise<Prisma.TaskRunGetPayload<{ select: S }>>;
|
||||
}
|
||||
|
||||
finalizeRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { select: S },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ select: S }>>;
|
||||
finalizeRun<I extends Prisma.TaskRunInclude>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { include: I },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ include: I }>>;
|
||||
finalizeRun(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<TaskRun>;
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
argsOrTx?:
|
||||
| { select?: Prisma.TaskRunSelect; include?: Prisma.TaskRunInclude }
|
||||
| PrismaClientOrTransaction,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<unknown> {
|
||||
// Disambiguate the 3rd positional: a `{ select | include }` projection vs. a tx client (a client
|
||||
// never carries a select/include own-key), mirroring #resolveReadArgs on the read path.
|
||||
const isProjection =
|
||||
typeof argsOrTx === "object" &&
|
||||
argsOrTx !== null &&
|
||||
("select" in argsOrTx || "include" in argsOrTx);
|
||||
const args = isProjection
|
||||
? (argsOrTx as { select?: Prisma.TaskRunSelect; include?: Prisma.TaskRunInclude })
|
||||
: {};
|
||||
const prisma =
|
||||
(isProjection ? tx : (argsOrTx as PrismaClientOrTransaction | undefined)) ?? this.prisma;
|
||||
|
||||
// status + error land in the SAME update (a separate later error write races realtime, which
|
||||
// shuts the stream on the final status before the error lands). undefined fields are skipped.
|
||||
return this.#updateTaskRunWithSelect(
|
||||
prisma,
|
||||
{ id: runId },
|
||||
{
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
...(data.expiredAt !== undefined && { expiredAt: data.expiredAt }),
|
||||
...(data.completedAt !== undefined && { completedAt: data.completedAt }),
|
||||
...(data.error !== undefined && { error: data.error as Prisma.InputJsonValue }),
|
||||
...(data.bulkActionId !== undefined && {
|
||||
bulkActionGroupIds: { push: data.bulkActionId },
|
||||
}),
|
||||
},
|
||||
args
|
||||
);
|
||||
}
|
||||
|
||||
async expireRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
data: {
|
||||
@@ -2381,6 +2441,70 @@ export class PostgresRunStore implements RunStore {
|
||||
return prisma.batchTaskRunItem.updateMany(args);
|
||||
}
|
||||
|
||||
// The item's `batchTaskRun`/`taskRun` relations stay real FKs on BOTH schemas (co-resident), so a
|
||||
// caller `include` passes straight through — no dedicated-subset stripping is needed.
|
||||
async findManyBatchTaskRunItems<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { taskRunId?: string; batchTaskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }>[]> {
|
||||
const prisma = client ?? this.readOnlyPrisma;
|
||||
|
||||
return prisma.batchTaskRunItem.findMany({
|
||||
where,
|
||||
...(args?.include ? { include: args.include } : {}),
|
||||
}) as Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }>[]>;
|
||||
}
|
||||
|
||||
async findBatchTaskRunItem<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { batchTaskRunId: string; taskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }> | null> {
|
||||
const prisma = client ?? this.readOnlyPrisma;
|
||||
|
||||
return prisma.batchTaskRunItem.findFirst({
|
||||
where,
|
||||
...(args?.include ? { include: args.include } : {}),
|
||||
}) as Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }> | null>;
|
||||
}
|
||||
|
||||
// --- WaitpointTag (run-ops) ---
|
||||
|
||||
async upsertWaitpointTag(
|
||||
data: { environmentId: string; name: string; projectId: string; id?: string },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<WaitpointTag> {
|
||||
const prisma = tx ?? this.prisma;
|
||||
|
||||
return prisma.waitpointTag.upsert({
|
||||
where: { environmentId_name: { environmentId: data.environmentId, name: data.name } },
|
||||
create: {
|
||||
...(data.id !== undefined && { id: data.id }),
|
||||
name: data.name,
|
||||
environmentId: data.environmentId,
|
||||
projectId: data.projectId,
|
||||
},
|
||||
update: {},
|
||||
}) as Promise<WaitpointTag>;
|
||||
}
|
||||
|
||||
async findManyWaitpointTags(
|
||||
args: {
|
||||
where: Prisma.WaitpointTagWhereInput;
|
||||
orderBy?:
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput[];
|
||||
take?: number;
|
||||
skip?: number;
|
||||
},
|
||||
client?: ReadClient
|
||||
): Promise<WaitpointTag[]> {
|
||||
const prisma = client ?? this.readOnlyPrisma;
|
||||
|
||||
return prisma.waitpointTag.findMany(args) as Promise<WaitpointTag[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `taskRun.update` honoring a caller `{ select | include }` that may name dedicated-schema
|
||||
* relation keys. Legacy passes through unchanged; dedicated strips + hydrates via the shared adapter.
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
//
|
||||
// Under the run-ops split, several engine operations that were atomic-by-`prisma.$transaction` in
|
||||
// single-DB make TWO distinct RunStore writes (e.g. startAttempt + createExecutionSnapshot, or
|
||||
// promotePendingVersionRuns + createExecutionSnapshot). When the run is run-ops id (#new), `RoutingRunStore`
|
||||
// routes each write to the NEW store but DROPS the caller's control-plane `tx` — so the two writes
|
||||
// execute as independent auto-commit statements on the NEW DB, OUTSIDE any shared transaction. A crash
|
||||
// between them leaves partial state (a run EXECUTING with no matching snapshot; promoted-but-no-snapshot).
|
||||
// promotePendingVersionRuns + createExecutionSnapshot). `RoutingRunStore` routes each write to its
|
||||
// owning store and NEVER threads the caller's control-plane `tx` into a sub-store (for BOTH
|
||||
// residencies) — so the two writes execute as independent auto-commit statements on the owning DB,
|
||||
// OUTSIDE any shared transaction. A crash between them leaves partial state (a run EXECUTING with no
|
||||
// matching snapshot; promoted-but-no-snapshot).
|
||||
//
|
||||
// `heteroRunOpsPostgresTest` gives the REAL production split: prisma17 = a real `RunOpsPrismaClient`
|
||||
// over the @internal/run-ops-database SUBSET schema (#new), prisma14 = the full control-plane schema on
|
||||
@@ -314,9 +315,9 @@ describe("cross-DB write atomicity (startAttempt + createExecutionSnapshot)", ()
|
||||
});
|
||||
|
||||
// A run's blocking edges may straddle both DBs mid-drain, so clearBlockingWaitpoints routes the
|
||||
// taskRunId-keyed delete through the both-stores fan-out. The #new leg can't join a control-plane
|
||||
// tx, but the #legacy leg CAN — so the caller's tx (e.g. attemptFailed) must still be honored for
|
||||
// the legacy edges, keeping them atomic with the caller's operation instead of auto-committing.
|
||||
// taskRunId-keyed delete through the both-stores fan-out. The caller's control-plane tx is NEVER
|
||||
// threaded into either leg (e.g. attemptFailed passes its base client): each leg deletes on its own
|
||||
// store's client, so the edges are removed independently of whether the caller's tx commits.
|
||||
async function seedLegacyBlockingEdge(
|
||||
prisma14: PrismaClient,
|
||||
env: { project: { id: string }; environment: { id: string } },
|
||||
@@ -339,9 +340,11 @@ async function seedLegacyBlockingEdge(
|
||||
});
|
||||
}
|
||||
|
||||
describe("fan-out deleteManyTaskRunWaitpoints honors the caller's tx on the #legacy leg", () => {
|
||||
describe("fan-out deleteManyTaskRunWaitpoints never threads the caller's tx into a sub-store", () => {
|
||||
// The routed delete runs on each store's OWN client, outside the caller's control-plane tx, so it
|
||||
// commits even though the caller's tx rolls back — proving the tx was not threaded through.
|
||||
heteroRunOpsPostgresTest(
|
||||
"rolls the #legacy edge delete back when the caller's control-plane tx rolls back",
|
||||
"deletes the #legacy edge even when the caller's control-plane tx rolls back",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "del_tx_rb");
|
||||
@@ -364,32 +367,8 @@ describe("fan-out deleteManyTaskRunWaitpoints honors the caller's tx on the #leg
|
||||
})
|
||||
).rejects.toThrow("rollback");
|
||||
|
||||
const remaining = await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } });
|
||||
expect(remaining).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"still deletes the #legacy edge when the caller's tx commits",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "del_tx_commit");
|
||||
const runId = `run_${CUID_25}`;
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_del_tx_commit",
|
||||
organizationId: env.organization.id,
|
||||
projectId: env.project.id,
|
||||
runtimeEnvironmentId: env.environment.id,
|
||||
})
|
||||
);
|
||||
await seedLegacyBlockingEdge(prisma14, env, runId, "del_tx_commit");
|
||||
|
||||
await prisma14.$transaction(async (tx) => {
|
||||
await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } }, tx);
|
||||
});
|
||||
|
||||
// The edge is gone: the routed delete auto-committed on the legacy store's own client and was
|
||||
// never enrolled in the caller's (rolled-back) transaction.
|
||||
const remaining = await prisma14.taskRunWaitpoint.count({ where: { taskRunId: runId } });
|
||||
expect(remaining).toBe(0);
|
||||
}
|
||||
@@ -461,12 +440,12 @@ describe("createExecutionSnapshot writes the snapshot and its completed-waitpoin
|
||||
);
|
||||
});
|
||||
|
||||
// RoutingRunStore.createExecutionSnapshot accepts a caller tx but must forward it to the OWNING store
|
||||
// only when that store is #legacy: a control-plane tx can't wrap a #new (cross-DB) write, but it can
|
||||
// (and should) wrap a legacy-resident snapshot so it stays atomic with the caller's operation.
|
||||
describe("createExecutionSnapshot honors the caller's tx on the #legacy owning store", () => {
|
||||
// RoutingRunStore.createExecutionSnapshot never threads a caller tx into the owning store: the write
|
||||
// runs on that store's own client (which opens its OWN transaction to keep the snapshot and its links
|
||||
// atomic), so it commits independently of the caller's control-plane tx.
|
||||
describe("createExecutionSnapshot never threads the caller's tx into the owning store", () => {
|
||||
heteroRunOpsPostgresTest(
|
||||
"rolls the snapshot back when a legacy run's caller tx rolls back",
|
||||
"persists a legacy run's snapshot even when the caller's control-plane tx rolls back",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "ces_rb");
|
||||
@@ -488,33 +467,8 @@ describe("createExecutionSnapshot honors the caller's tx on the #legacy owning s
|
||||
})
|
||||
).rejects.toThrow("rollback");
|
||||
|
||||
const snap = await prisma14.taskRunExecutionSnapshot.findFirst({
|
||||
where: { runId, executionStatus: "EXECUTING" },
|
||||
});
|
||||
expect(snap).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"persists the snapshot when the legacy caller tx commits",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "ces_commit");
|
||||
const runId = `run_${CUID_25}`;
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_ces_commit",
|
||||
organizationId: env.organization.id,
|
||||
projectId: env.project.id,
|
||||
runtimeEnvironmentId: env.environment.id,
|
||||
})
|
||||
);
|
||||
|
||||
await prisma14.$transaction(async (tx) => {
|
||||
await router.createExecutionSnapshot(snapshotInput(runId, env), tx);
|
||||
});
|
||||
|
||||
// The snapshot survived the caller's rollback: it was written on the legacy store's own client
|
||||
// in its own transaction, never enrolled in the caller's (rolled-back) control-plane tx.
|
||||
const snap = await prisma14.taskRunExecutionSnapshot.findFirst({
|
||||
where: { runId, executionStatus: "EXECUTING" },
|
||||
});
|
||||
@@ -713,3 +667,116 @@ describe("createExecutionSnapshot / lockRunToWorker write the snapshot and its l
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Direct (not behavioural) proof of the never-forward invariant: a recording proxy over each REAL
|
||||
// sub-store captures the arguments every routed call receives, so we can assert the SECOND (tx)
|
||||
// argument the router hands each sub-store is `undefined`. No mocks — the real PostgresRunStore does
|
||||
// the DB work; the proxy only observes. Property access (e.g. the `primaryReadClient` getter) runs on
|
||||
// the real instance so its private fields resolve; only methods are wrapped.
|
||||
type RecordedCall = { method: string; args: unknown[] };
|
||||
|
||||
function recordingStore(inner: RunStore, calls: RecordedCall[]): RunStore {
|
||||
return new Proxy(inner as unknown as Record<string | symbol, unknown>, {
|
||||
get(target, prop) {
|
||||
const value = target[prop];
|
||||
if (typeof value === "function") {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method: String(prop), args });
|
||||
return (value as (...a: unknown[]) => unknown).apply(target, args);
|
||||
};
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as unknown as RunStore;
|
||||
}
|
||||
|
||||
// The tx (second) argument of every recorded call to `method`.
|
||||
function txArgsFor(calls: RecordedCall[], method: string): unknown[] {
|
||||
return calls.filter((c) => c.method === method).map((c) => c.args[1]);
|
||||
}
|
||||
|
||||
describe("RoutingRunStore never threads a caller tx into either sub-store (recorded)", () => {
|
||||
heteroRunOpsPostgresTest(
|
||||
"createExecutionSnapshot hands the #legacy sub-store an undefined tx (cuid run)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const legacyCalls: RecordedCall[] = [];
|
||||
const newCalls: RecordedCall[] = [];
|
||||
const router = new RoutingRunStore({
|
||||
new: recordingStore(makeDedicatedStore(prisma17), newCalls),
|
||||
legacy: recordingStore(makeLegacyStore(prisma14), legacyCalls),
|
||||
});
|
||||
const env = await seedEnvironment(prisma14, "legacy", "spy_ces_leg");
|
||||
const runId = `run_${CUID_25}`;
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_spy_ces_leg",
|
||||
organizationId: env.organization.id,
|
||||
projectId: env.project.id,
|
||||
runtimeEnvironmentId: env.environment.id,
|
||||
})
|
||||
);
|
||||
|
||||
// Thread the base control-plane client as `tx`, exactly as the engine does (`tx ?? this.$.prisma`).
|
||||
await router.createExecutionSnapshot(snapshotInput(runId, env), prisma14);
|
||||
|
||||
const forwarded = txArgsFor(legacyCalls, "createExecutionSnapshot");
|
||||
expect(forwarded.length).toBeGreaterThan(0);
|
||||
for (const arg of forwarded) expect(arg).toBeUndefined();
|
||||
// A cuid run's snapshot must not touch the #new store at all.
|
||||
expect(txArgsFor(newCalls, "createExecutionSnapshot")).toHaveLength(0);
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"createExecutionSnapshot hands the #new sub-store an undefined tx (run-ops id)",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const legacyCalls: RecordedCall[] = [];
|
||||
const newCalls: RecordedCall[] = [];
|
||||
const router = new RoutingRunStore({
|
||||
new: recordingStore(makeDedicatedStore(prisma17), newCalls),
|
||||
legacy: recordingStore(makeLegacyStore(prisma14), legacyCalls),
|
||||
});
|
||||
const { runId, env } = await seedRunOpsRun(router, prisma17, "spy_ces_new");
|
||||
|
||||
await router.createExecutionSnapshot(snapshotInput(runId, env), prisma14);
|
||||
|
||||
const forwarded = txArgsFor(newCalls, "createExecutionSnapshot");
|
||||
expect(forwarded.length).toBeGreaterThan(0);
|
||||
for (const arg of forwarded) expect(arg).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
heteroRunOpsPostgresTest(
|
||||
"deleteManyTaskRunWaitpoints fan-out hands BOTH sub-stores an undefined tx",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const legacyCalls: RecordedCall[] = [];
|
||||
const newCalls: RecordedCall[] = [];
|
||||
const router = new RoutingRunStore({
|
||||
new: recordingStore(makeDedicatedStore(prisma17), newCalls),
|
||||
legacy: recordingStore(makeLegacyStore(prisma14), legacyCalls),
|
||||
});
|
||||
const env = await seedEnvironment(prisma14, "legacy", "spy_del");
|
||||
const runId = `run_${CUID_25}`;
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_spy_del",
|
||||
organizationId: env.organization.id,
|
||||
projectId: env.project.id,
|
||||
runtimeEnvironmentId: env.environment.id,
|
||||
})
|
||||
);
|
||||
await seedLegacyBlockingEdge(prisma14, env, runId, "spy_del");
|
||||
|
||||
// Keyed by taskRunId → the both-stores fan-out branch. Pass the base control-plane client as tx.
|
||||
await router.deleteManyTaskRunWaitpoints({ where: { taskRunId: runId } }, prisma14);
|
||||
|
||||
const legacyTx = txArgsFor(legacyCalls, "deleteManyTaskRunWaitpoints");
|
||||
const newTx = txArgsFor(newCalls, "deleteManyTaskRunWaitpoints");
|
||||
expect(legacyTx.length).toBeGreaterThan(0);
|
||||
expect(newTx.length).toBeGreaterThan(0);
|
||||
for (const arg of [...legacyTx, ...newTx]) expect(arg).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,10 +152,10 @@ describe("run-ops split — BatchTaskRun writes/probes must NOT forward the cont
|
||||
}
|
||||
);
|
||||
|
||||
// Control: a cuid batch on #legacy still updates through the router when the same (legacy) client
|
||||
// is passed as tx — the tx IS forwarded for LEGACY (same physical DB), so atomicity is preserved.
|
||||
// Control: a cuid batch on #legacy still updates through the router when a client is passed as tx.
|
||||
// The caller's tx is dropped and the routed write runs on the legacy store's own client.
|
||||
heteroRunOpsPostgresTest(
|
||||
"updateBatchTaskRun control: a cuid batch on #legacy still updates with the control-plane tx forwarded",
|
||||
"updateBatchTaskRun control: a cuid batch on #legacy updates with the caller's tx dropped",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "updbatch_leg");
|
||||
@@ -214,9 +214,10 @@ describe("run-ops split — BatchTaskRun writes/probes must NOT forward the cont
|
||||
}
|
||||
);
|
||||
|
||||
// Control: a cuid batch is created on #legacy with the same control-plane tx forwarded (same DB).
|
||||
// Control: a cuid batch is created on #legacy with the caller's tx dropped — the routed create
|
||||
// runs on the legacy store's own client.
|
||||
heteroRunOpsPostgresTest(
|
||||
"createBatchTaskRun control: a cuid batch lands on #legacy with the control-plane tx forwarded",
|
||||
"createBatchTaskRun control: a cuid batch lands on #legacy with the caller's tx dropped",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { router } = makeSplitRouter(prisma14, prisma17);
|
||||
const env = await seedEnvironment(prisma14, "legacy", "crbatch_leg");
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RoutingRunStore } from "./runOpsStore.js";
|
||||
import type { FinalizeRunData, RunStore } from "./types.js";
|
||||
|
||||
// Pure routing unit tests for the five store methods added in Track 1. No DB: each slot is a fake
|
||||
// RunStore that records the calls it receives, so the assertions are purely about WHICH store the
|
||||
// router dispatches to (by residency key) and WHAT it forwards (never a control-plane tx into a
|
||||
// routed write; caller client presence escalates to the owning store's own primary).
|
||||
|
||||
type Call = { method: string; args: unknown[] };
|
||||
|
||||
type FakeStore = RunStore & {
|
||||
slot: "new" | "legacy";
|
||||
calls: Call[];
|
||||
primaryReadClient: { __primary: "new" | "legacy" };
|
||||
};
|
||||
|
||||
function fakeStore(slot: "new" | "legacy"): FakeStore {
|
||||
const calls: Call[] = [];
|
||||
const record =
|
||||
(method: string, result: unknown) =>
|
||||
(...args: unknown[]) => {
|
||||
calls.push({ method, args });
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
return {
|
||||
slot,
|
||||
calls,
|
||||
primaryReadClient: { __primary: slot },
|
||||
finalizeRun: record("finalizeRun", { slot, kind: "run" }),
|
||||
findManyBatchTaskRunItems: record("findManyBatchTaskRunItems", [{ slot }]),
|
||||
findBatchTaskRunItem: record("findBatchTaskRunItem", { slot }),
|
||||
upsertWaitpointTag: record("upsertWaitpointTag", { slot }),
|
||||
// Slot-specific rows so the merge/dedupe (NEW-wins) is observable; id "a" collides across legs.
|
||||
findManyWaitpointTags: record(
|
||||
"findManyWaitpointTags",
|
||||
slot === "new"
|
||||
? [
|
||||
{ id: "b", src: "new" },
|
||||
{ id: "a", src: "new" },
|
||||
]
|
||||
: [
|
||||
{ id: "c", src: "legacy" },
|
||||
{ id: "a", src: "legacy" },
|
||||
]
|
||||
),
|
||||
} as unknown as FakeStore;
|
||||
}
|
||||
|
||||
// Deterministic residency by id prefix, injected via the classify seam so the tests don't depend on
|
||||
// id-shape length rules.
|
||||
function buildRouter() {
|
||||
const newStore = fakeStore("new");
|
||||
const legacyStore = fakeStore("legacy");
|
||||
const router = new RoutingRunStore({
|
||||
new: newStore,
|
||||
legacy: legacyStore,
|
||||
classify: (id: string) => (id.startsWith("new") ? "NEW" : "LEGACY"),
|
||||
});
|
||||
return { router, newStore, legacyStore };
|
||||
}
|
||||
|
||||
const DATA: FinalizeRunData = { status: "COMPLETED_SUCCESSFULLY", completedAt: new Date() };
|
||||
|
||||
describe("RoutingRunStore.finalizeRun", () => {
|
||||
it("routes by runId and forwards the projection, never the tx", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
const projection = { select: { id: true } };
|
||||
await router.finalizeRun("new_run", DATA, projection);
|
||||
expect(newStore.calls).toHaveLength(1);
|
||||
expect(newStore.calls[0]?.args).toEqual(["new_run", DATA, projection]);
|
||||
expect(legacyStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("routes a cuid/legacy runId to the legacy store", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.finalizeRun("legacy_run", DATA, { include: { attempts: true } });
|
||||
expect(legacyStore.calls[0]?.args).toEqual([
|
||||
"legacy_run",
|
||||
DATA,
|
||||
{ include: { attempts: true } },
|
||||
]);
|
||||
expect(newStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a caller-passed control-plane tx (never threaded into the routed write)", async () => {
|
||||
const { router, legacyStore } = buildRouter();
|
||||
const controlPlaneTx = { $fake: "cp-tx" };
|
||||
await router.finalizeRun("legacy_run", DATA, controlPlaneTx as never);
|
||||
// The tx is neither a select/include projection nor forwarded: the sub-store sees a 3-arg call
|
||||
// whose projection slot is undefined.
|
||||
expect(legacyStore.calls[0]?.args).toEqual(["legacy_run", DATA, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoutingRunStore.findManyBatchTaskRunItems", () => {
|
||||
it("routes by batchTaskRunId first", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.findManyBatchTaskRunItems({
|
||||
batchTaskRunId: "new_batch",
|
||||
taskRunId: "legacy_run",
|
||||
});
|
||||
expect(newStore.calls[0]?.method).toBe("findManyBatchTaskRunItems");
|
||||
expect(legacyStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to taskRunId when no batchTaskRunId is present", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.findManyBatchTaskRunItems({ taskRunId: "legacy_run" });
|
||||
expect(legacyStore.calls[0]?.method).toBe("findManyBatchTaskRunItems");
|
||||
expect(newStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("escalates a caller client to the owning store's own primary (read-your-writes)", async () => {
|
||||
const { router, newStore } = buildRouter();
|
||||
// A non-replica client object signals read-your-writes; it must NOT be forwarded verbatim.
|
||||
await router.findManyBatchTaskRunItems({ batchTaskRunId: "new_batch" }, undefined, {
|
||||
writer: true,
|
||||
} as never);
|
||||
expect(newStore.calls[0]?.args[2]).toEqual({ __primary: "new" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoutingRunStore.findBatchTaskRunItem", () => {
|
||||
it("routes by batchTaskRunId", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.findBatchTaskRunItem({ batchTaskRunId: "legacy_batch", taskRunId: "new_run" });
|
||||
expect(legacyStore.calls[0]?.method).toBe("findBatchTaskRunItem");
|
||||
expect(newStore.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoutingRunStore.upsertWaitpointTag", () => {
|
||||
it("routes the write by the tag's minted id-shape (env mint-kind)", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.upsertWaitpointTag({
|
||||
environmentId: "env",
|
||||
name: "t",
|
||||
projectId: "p",
|
||||
id: "new_tag",
|
||||
});
|
||||
expect(newStore.calls[0]?.method).toBe("upsertWaitpointTag");
|
||||
expect(legacyStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to legacy when no minted id is supplied", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
await router.upsertWaitpointTag({ environmentId: "env", name: "t", projectId: "p" });
|
||||
expect(legacyStore.calls[0]?.method).toBe("upsertWaitpointTag");
|
||||
expect(newStore.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never threads a control-plane tx into either leg", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
const tx = { $fake: "cp-tx" };
|
||||
await router.upsertWaitpointTag(
|
||||
{ environmentId: "env", name: "t", projectId: "p", id: "legacy_tag" },
|
||||
tx as never
|
||||
);
|
||||
// The routed write runs on the owning store's own client, so the tx is dropped on the LEGACY leg too.
|
||||
expect(legacyStore.calls[0]?.args[1]).toBeUndefined();
|
||||
|
||||
const tx2 = { $fake: "cp-tx-2" };
|
||||
await router.upsertWaitpointTag(
|
||||
{ environmentId: "env", name: "t", projectId: "p", id: "new_tag" },
|
||||
tx2 as never
|
||||
);
|
||||
// NEW leg likewise never receives the control-plane tx.
|
||||
expect(newStore.calls[0]?.args[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoutingRunStore.findManyWaitpointTags", () => {
|
||||
it("fans out to both stores, de-dupes NEW-wins, and re-imposes orderBy/take/skip globally", async () => {
|
||||
const { router, newStore, legacyStore } = buildRouter();
|
||||
const result = (await router.findManyWaitpointTags({
|
||||
where: { environmentId: "env" },
|
||||
orderBy: { id: "desc" },
|
||||
take: 2,
|
||||
skip: 1,
|
||||
})) as Array<{ id: string; src: string }>;
|
||||
|
||||
// Union {a,b,c} sorted desc = [c,b,a]; slice(1,3) = [b,a]; "a" collides so NEW wins.
|
||||
expect(result.map((r) => r.id)).toEqual(["b", "a"]);
|
||||
expect(result.find((r) => r.id === "a")?.src).toBe("new");
|
||||
|
||||
// Each leg is widened: skip dropped to 0, take widened to skip+take.
|
||||
expect((newStore.calls[0]!.args[0] as { take: number; skip: number }).take).toBe(3);
|
||||
expect((newStore.calls[0]!.args[0] as { take: number; skip: number }).skip).toBe(0);
|
||||
expect((legacyStore.calls[0]!.args[0] as { take: number; skip: number }).take).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
// Track 2 THREE-database topology proof. Before Track 2 the legacy run-ops client was an ALIAS of the
|
||||
// control-plane client (legacyRunOps = controlPlane), so a cuid run's rows physically landed in the
|
||||
// control-plane DB. Track 2 makes the legacy client INDEPENDENT (its own DSN). This test stands up
|
||||
// three DISTINCT physical databases — control-plane, legacy, new — and proves:
|
||||
// - a cuid (LEGACY) run's routed create/read lands on the LEGACY DB, and is ABSENT from both the
|
||||
// control-plane DB and the new DB;
|
||||
// - a run-ops id (NEW) run's routed create/read lands on the NEW DB, and is ABSENT from both the
|
||||
// legacy DB and the control-plane DB;
|
||||
// - control-plane-model access (Organization) stays on the control-plane DB, unaffected by and
|
||||
// invisible to the legacy DB — i.e. legacy is genuinely NOT the control-plane DB anymore.
|
||||
//
|
||||
// `threeDbRunOpsPostgresTest` gives controlPlanePrisma + legacyPrisma (two SEPARATE clones of the full
|
||||
// control-plane schema) and newPrisma (the @internal/run-ops-database SUBSET schema on its own
|
||||
// container). NEVER mocked — three real Postgres databases.
|
||||
|
||||
import { threeDbRunOpsPostgresTest } from "@internal/testcontainers";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
|
||||
import { describe, expect } from "vitest";
|
||||
import { PostgresRunStore } from "./PostgresRunStore.js";
|
||||
import { RoutingRunStore } from "./runOpsStore.js";
|
||||
import type { CreateRunInput, RunStoreSchemaVariant } from "./types.js";
|
||||
|
||||
type AnyClient = PrismaClient | RunOpsPrismaClient;
|
||||
|
||||
// ownerEngine classifies the internal id (after stripping a single `<prefix>_`): 25-char body → cuid →
|
||||
// LEGACY; a v1 body (version "1" at index 25) → run-ops id → NEW.
|
||||
const CUID_25 = "c".repeat(25); // → LEGACY (legacy full-schema DB)
|
||||
const NEW_ID_26 = "k".repeat(24) + "01"; // → NEW (dedicated subset DB)
|
||||
|
||||
async function seedEnvironmentLegacy(prisma: PrismaClient, suffix: string) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `Project ${suffix}`,
|
||||
slug: `project-${suffix}`,
|
||||
externalRef: `proj_${suffix}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "DEVELOPMENT",
|
||||
slug: "dev",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${suffix}`,
|
||||
pkApiKey: `pk_dev_${suffix}`,
|
||||
shortcode: `short_${suffix}`,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
// On the dedicated subset there are no Organization/Project/RuntimeEnvironment models (the run-ops rows
|
||||
// carry FK-free scalar ids), so synthetic owning ids are enough.
|
||||
function seedEnvironmentDedicated(suffix: string) {
|
||||
return {
|
||||
organization: { id: `org_${suffix}` },
|
||||
project: { id: `proj_${suffix}` },
|
||||
environment: { id: `env_${suffix}` },
|
||||
};
|
||||
}
|
||||
|
||||
function buildCreateRunInput(params: {
|
||||
runId: string;
|
||||
friendlyId: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
}): CreateRunInput {
|
||||
return {
|
||||
data: {
|
||||
id: params.runId,
|
||||
engine: "V2",
|
||||
status: "PENDING",
|
||||
friendlyId: params.friendlyId,
|
||||
runtimeEnvironmentId: params.runtimeEnvironmentId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
organizationId: params.organizationId,
|
||||
projectId: params.projectId,
|
||||
taskIdentifier: "my-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceContext: {},
|
||||
traceId: `trace_${params.runId}`,
|
||||
spanId: `span_${params.runId}`,
|
||||
queue: "task/my-task",
|
||||
isTest: false,
|
||||
taskEventStore: "taskEvent",
|
||||
depth: 0,
|
||||
},
|
||||
snapshot: {
|
||||
engine: "V2",
|
||||
executionStatus: "RUN_CREATED",
|
||||
description: "Run was created",
|
||||
runStatus: "PENDING",
|
||||
environmentId: params.runtimeEnvironmentId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
projectId: params.projectId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(prisma: AnyClient, schemaVariant: RunStoreSchemaVariant) {
|
||||
return new PostgresRunStore({
|
||||
prisma: prisma as never,
|
||||
readOnlyPrisma: prisma as never,
|
||||
schemaVariant,
|
||||
});
|
||||
}
|
||||
|
||||
async function findRunId(client: AnyClient, id: string): Promise<string | null> {
|
||||
const row = await (client as PrismaClient).taskRun.findFirst({
|
||||
where: { id },
|
||||
select: { friendlyId: true },
|
||||
});
|
||||
return row?.friendlyId ?? null;
|
||||
}
|
||||
|
||||
describe("run-ops split — three-database topology (control-plane ≠ legacy ≠ new)", () => {
|
||||
threeDbRunOpsPostgresTest(
|
||||
"a cuid run routes to the LEGACY DB and never touches control-plane or new",
|
||||
async ({ controlPlanePrisma, legacyPrisma, newPrisma }) => {
|
||||
const legacyStore = makeStore(legacyPrisma, "legacy");
|
||||
const newStore = makeStore(newPrisma, "dedicated");
|
||||
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
||||
|
||||
const seed = await seedEnvironmentLegacy(legacyPrisma, "cuid_leg");
|
||||
const runId = `run_${CUID_25}`; // → LEGACY
|
||||
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_cuid_legacy",
|
||||
organizationId: seed.organization.id,
|
||||
projectId: seed.project.id,
|
||||
runtimeEnvironmentId: seed.environment.id,
|
||||
})
|
||||
);
|
||||
|
||||
// WRITE landed on the LEGACY physical DB only.
|
||||
expect(await findRunId(legacyPrisma, runId)).toBe("run_cuid_legacy");
|
||||
expect(await findRunId(controlPlanePrisma, runId)).toBeNull();
|
||||
expect(await findRunId(newPrisma, runId)).toBeNull();
|
||||
|
||||
// Routed READ resolves the run (from the legacy DB).
|
||||
const read = await router.findRun({ id: runId }, { select: { friendlyId: true } });
|
||||
expect(read?.friendlyId).toBe("run_cuid_legacy");
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
threeDbRunOpsPostgresTest(
|
||||
"a run-ops id run routes to the NEW DB and never touches legacy or control-plane",
|
||||
async ({ controlPlanePrisma, legacyPrisma, newPrisma }) => {
|
||||
const legacyStore = makeStore(legacyPrisma, "legacy");
|
||||
const newStore = makeStore(newPrisma, "dedicated");
|
||||
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
||||
|
||||
const seed = seedEnvironmentDedicated("newid");
|
||||
const runId = `run_${NEW_ID_26}`; // → NEW
|
||||
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_ops_new",
|
||||
organizationId: seed.organization.id,
|
||||
projectId: seed.project.id,
|
||||
runtimeEnvironmentId: seed.environment.id,
|
||||
})
|
||||
);
|
||||
|
||||
// WRITE landed on the NEW physical DB only.
|
||||
expect(await findRunId(newPrisma, runId)).toBe("run_ops_new");
|
||||
expect(await findRunId(legacyPrisma, runId)).toBeNull();
|
||||
expect(await findRunId(controlPlanePrisma, runId)).toBeNull();
|
||||
|
||||
// Routed READ resolves the run (from the new DB).
|
||||
const read = await router.findRun({ id: runId }, { select: { friendlyId: true } });
|
||||
expect(read?.friendlyId).toBe("run_ops_new");
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
threeDbRunOpsPostgresTest(
|
||||
"control-plane-model access stays on the control-plane DB, independent of the legacy DB",
|
||||
async ({ controlPlanePrisma, legacyPrisma, newPrisma }) => {
|
||||
const legacyStore = makeStore(legacyPrisma, "legacy");
|
||||
const newStore = makeStore(newPrisma, "dedicated");
|
||||
const router = new RoutingRunStore({ new: newStore, legacy: legacyStore });
|
||||
|
||||
// A control-plane-model write goes to the control-plane DB.
|
||||
const cpOrg = await controlPlanePrisma.organization.create({
|
||||
data: { title: "CP Org", slug: "cp-org" },
|
||||
});
|
||||
|
||||
// Route a cuid run through the store (writes run-graph rows to the LEGACY DB) alongside the
|
||||
// control-plane org — the two must not bleed across databases.
|
||||
const seed = await seedEnvironmentLegacy(legacyPrisma, "cp_indep");
|
||||
const runId = `run_${CUID_25}`;
|
||||
await router.createRun(
|
||||
buildCreateRunInput({
|
||||
runId,
|
||||
friendlyId: "run_cp_indep",
|
||||
organizationId: seed.organization.id,
|
||||
projectId: seed.project.id,
|
||||
runtimeEnvironmentId: seed.environment.id,
|
||||
})
|
||||
);
|
||||
|
||||
// The control-plane org lives on the control-plane DB and is INVISIBLE to the legacy DB.
|
||||
expect(
|
||||
await controlPlanePrisma.organization.findFirst({ where: { id: cpOrg.id } })
|
||||
).not.toBeNull();
|
||||
expect(await legacyPrisma.organization.findFirst({ where: { id: cpOrg.id } })).toBeNull();
|
||||
|
||||
// The legacy-seeded org lives on the legacy DB and is INVISIBLE to the control-plane DB —
|
||||
// proving legacy is genuinely a separate physical database from control-plane.
|
||||
expect(
|
||||
await legacyPrisma.organization.findFirst({ where: { id: seed.organization.id } })
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await controlPlanePrisma.organization.findFirst({ where: { id: seed.organization.id } })
|
||||
).toBeNull();
|
||||
|
||||
// And the legacy run never leaked onto the control-plane DB.
|
||||
expect(await findRunId(legacyPrisma, runId)).toBe("run_cp_indep");
|
||||
expect(await findRunId(controlPlanePrisma, runId)).toBeNull();
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
PrismaClientOrTransaction,
|
||||
TaskRun,
|
||||
TaskRunStatus,
|
||||
WaitpointTag,
|
||||
} from "@trigger.dev/database";
|
||||
import { ownerEngine, type Residency } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { TaskRunError } from "@trigger.dev/core/v3/schemas";
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
CreateFailedRunInput,
|
||||
CreateRunInput,
|
||||
ExpireSnapshotInput,
|
||||
FinalizeRunData,
|
||||
ForWaitpointCompletionContext,
|
||||
LockRunData,
|
||||
ReadClient,
|
||||
@@ -137,15 +139,16 @@ export class RoutingRunStore implements RunStore {
|
||||
|
||||
// A waitpoint WRITE co-locates with its run by id-shape (cuid → LEGACY, run-ops id → NEW,
|
||||
// unclassifiable → LEGACY), mirroring how `blockRunWithWaitpointEdges` routes the edge by
|
||||
// run id. `tx` is forwarded only to LEGACY (same physical DB as the control-plane tx);
|
||||
// for NEW it's dropped so the row lands on NEW's own client.
|
||||
// run id. The caller's `tx` is never forwarded: a routed write must run on the OWNING store's
|
||||
// OWN client so the row lands in that store's database (same-store atomicity comes from the
|
||||
// owning store opening its own transaction, never from a caller-supplied one).
|
||||
#routeWaitpointWrite(
|
||||
id: string | undefined,
|
||||
tx?: PrismaClientOrTransaction
|
||||
_tx?: PrismaClientOrTransaction
|
||||
): { store: RunStore; tx?: PrismaClientOrTransaction } {
|
||||
const store =
|
||||
typeof id === "string" && this.#classifySafe(id) === "NEW" ? this.#new : this.#legacy;
|
||||
return { store, tx: store === this.#legacy ? tx : undefined };
|
||||
return { store, tx: undefined };
|
||||
}
|
||||
|
||||
// Resolve which store ACTUALLY holds a waitpoint id: drain-on-read can relocate a cuid
|
||||
@@ -479,10 +482,11 @@ export class RoutingRunStore implements RunStore {
|
||||
params: ClearIdempotencyKeyInput,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<{ count: number }> {
|
||||
// `byId` has a single classifiable run id — route on it.
|
||||
// `byId` has a single classifiable run id — route on it. The caller's `tx` is never
|
||||
// forwarded (a routed write runs on the owning store's own client).
|
||||
if ("byId" in params && params.byId) {
|
||||
const store = this.#route(params.byId.runId);
|
||||
return store.clearIdempotencyKey(params, store === this.#legacy ? tx : undefined);
|
||||
return store.clearIdempotencyKey(params, undefined);
|
||||
}
|
||||
// `byFriendlyIds` / `byPredicate` can span mixed residency — fan out and sum.
|
||||
return Promise.all([
|
||||
@@ -576,6 +580,37 @@ export class RoutingRunStore implements RunStore {
|
||||
return (await this.#routeForWrite(runId)).failRunPermanently(runId, data, args);
|
||||
}
|
||||
|
||||
finalizeRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { select: S },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ select: S }>>;
|
||||
finalizeRun<I extends Prisma.TaskRunInclude>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { include: I },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ include: I }>>;
|
||||
finalizeRun(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<TaskRun>;
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
argsOrTx?: { select?: unknown; include?: unknown } | PrismaClientOrTransaction,
|
||||
_tx?: PrismaClientOrTransaction
|
||||
): Promise<unknown> {
|
||||
// A finalize targets an existing run — route by its id. NEVER forward the caller's control-plane
|
||||
// tx into the routed write (§0.2); the finalize + its co-resident follow-ups need no cross-DB tx.
|
||||
// Only the select/include projection is forwarded; any passed tx is dropped.
|
||||
const args = selectOrIncludeArgs(argsOrTx);
|
||||
const store = await this.#routeForWrite(runId);
|
||||
return (store.finalizeRun as (...rest: unknown[]) => Promise<unknown>)(runId, data, args);
|
||||
}
|
||||
|
||||
async expireRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
data: {
|
||||
@@ -910,10 +945,11 @@ export class RoutingRunStore implements RunStore {
|
||||
input: CreateExecutionSnapshotInput,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunExecutionSnapshotGetPayload<{ include: { checkpoint: true } }>> {
|
||||
// Forward the caller's control-plane tx only to the #legacy store; a #new (cross-DB) write can't
|
||||
// join it, so it's dropped there (the atomic #new path uses runInTransaction instead).
|
||||
// The caller's `tx` is never forwarded: the write runs on the owning store's own client,
|
||||
// which opens its own transaction to keep the snapshot and its links atomic. Same-store
|
||||
// atomicity with a sibling write (e.g. startAttempt) is achieved via runInTransaction.
|
||||
const store = await this.#routeOrNewForWrite(input.run.id);
|
||||
return store.createExecutionSnapshot(input, store === this.#legacy ? tx : undefined);
|
||||
return store.createExecutionSnapshot(input, undefined);
|
||||
}
|
||||
|
||||
// Snapshot ids are cuids (they always classify LEGACY), and a snapshot's CompletedWaitpoint join
|
||||
@@ -1003,7 +1039,10 @@ export class RoutingRunStore implements RunStore {
|
||||
batchIndex?: number;
|
||||
tx?: PrismaClientOrTransaction;
|
||||
}): Promise<void> {
|
||||
return (await this.#routeOrNewForWrite(params.runId)).blockRunWithWaitpointEdges(params);
|
||||
// Route by run id; a caller-supplied `tx` is stripped so the edge write runs on the owning
|
||||
// store's own client rather than a caller-supplied (control-plane) connection.
|
||||
const { tx: _tx, ...edges } = params;
|
||||
return (await this.#routeOrNewForWrite(params.runId)).blockRunWithWaitpointEdges(edges);
|
||||
}
|
||||
|
||||
// A run's waitpoints can be scattered across both stores (drain in flight), so count on
|
||||
@@ -1257,7 +1296,7 @@ export class RoutingRunStore implements RunStore {
|
||||
: opts?.coLocateWithRunId !== undefined
|
||||
? this.#routeOrNew(opts.coLocateWithRunId)
|
||||
: await this.#resolveWaitpointStore(undefined);
|
||||
return store.updateWaitpoint(args, store === this.#legacy ? tx : undefined);
|
||||
return store.updateWaitpoint(args, undefined);
|
||||
}
|
||||
|
||||
async updateManyWaitpoints(
|
||||
@@ -1267,7 +1306,7 @@ export class RoutingRunStore implements RunStore {
|
||||
const id = RoutingRunStore.#waitpointId(args.where);
|
||||
if (id !== undefined) {
|
||||
const store = await this.#resolveWaitpointStore(id);
|
||||
return store.updateManyWaitpoints(args, store === this.#legacy ? tx : undefined);
|
||||
return store.updateManyWaitpoints(args, undefined);
|
||||
}
|
||||
// No single routable id (batch where): apply to both stores and sum.
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
@@ -1406,18 +1445,12 @@ export class RoutingRunStore implements RunStore {
|
||||
args: Prisma.TaskRunWaitpointDeleteManyArgs,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.BatchPayload> {
|
||||
const where = args.where as { waitpointId?: unknown } | undefined;
|
||||
const waitpointId = typeof where?.waitpointId === "string" ? where.waitpointId : undefined;
|
||||
if (waitpointId !== undefined) {
|
||||
const store = await this.#resolveWaitpointStore(waitpointId);
|
||||
return store.deleteManyTaskRunWaitpoints(args, store === this.#legacy ? tx : undefined);
|
||||
}
|
||||
// Keyed by taskRunId (or other): a run's edges may straddle DBs mid-drain, so delete from both.
|
||||
// One tx can't span two DBs, so the #new leg is auto-commit; the caller's tx is control-plane, so
|
||||
// the #legacy leg keeps it (atomic with the caller's op, matching the waitpointId-keyed path).
|
||||
// Edges co-locate with their RUN, not their waitpoint, so a waitpointId/taskRunId predicate may
|
||||
// match edges on either store; delete from both so no cross-DB edge keeps a run blocked. The
|
||||
// caller's `tx` is never forwarded — each leg deletes on its own store's client.
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
this.#new.deleteManyTaskRunWaitpoints(args),
|
||||
this.#legacy.deleteManyTaskRunWaitpoints(args, tx),
|
||||
this.#legacy.deleteManyTaskRunWaitpoints(args),
|
||||
]);
|
||||
return { count: fromNew.count + fromLegacy.count };
|
||||
}
|
||||
@@ -1453,14 +1486,15 @@ export class RoutingRunStore implements RunStore {
|
||||
}
|
||||
|
||||
// Co-locate the checkpoint with its OWNING run so the run-routed snapshot's `checkpointId` FK
|
||||
// resolves on the same DB. Route by `ownerRunId`; tx forwards only to LEGACY.
|
||||
// resolves on the same DB. Route by `ownerRunId`; the caller's `tx` is never forwarded (the
|
||||
// write runs on the owning store's own client).
|
||||
async createTaskRunCheckpoint<T extends Prisma.TaskRunCheckpointCreateArgs>(
|
||||
args: Prisma.SelectSubset<T, Prisma.TaskRunCheckpointCreateArgs>,
|
||||
ownerRunId?: string,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunCheckpointGetPayload<T>> {
|
||||
const store = this.#routeOrNew(ownerRunId);
|
||||
return store.createTaskRunCheckpoint(args, ownerRunId, store === this.#legacy ? tx : undefined);
|
||||
return store.createTaskRunCheckpoint(args, ownerRunId, undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1471,12 +1505,12 @@ export class RoutingRunStore implements RunStore {
|
||||
data: CreateBatchTaskRunData,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<BatchTaskRun> {
|
||||
// Route by the batch's classifiable internal id: run-ops id→NEW, cuid→LEGACY.
|
||||
// Never forward a control-plane tx to NEW (the create would land in the wrong DB, stranding the
|
||||
// run-ops batch + its co-resident child runs/items); forward tx only to LEGACY (same physical DB
|
||||
// as the tx). Mirrors #routeWaitpointWrite / updateBatchTaskRun.
|
||||
// Route by the batch's classifiable internal id: run-ops id→NEW, cuid→LEGACY. The caller's
|
||||
// `tx` is never forwarded — the create runs on the owning store's own client so the batch and
|
||||
// its co-resident child runs/items land on the same DB. Mirrors #routeWaitpointWrite /
|
||||
// updateBatchTaskRun.
|
||||
const store = await this.#routeOrNewForWrite(data.id);
|
||||
return store.createBatchTaskRun(data, store === this.#legacy ? tx : undefined);
|
||||
return store.createBatchTaskRun(data, undefined);
|
||||
}
|
||||
|
||||
updateBatchTaskRun<S extends Prisma.BatchTaskRunSelect>(
|
||||
@@ -1489,10 +1523,10 @@ export class RoutingRunStore implements RunStore {
|
||||
): Promise<Prisma.BatchTaskRunGetPayload<{ select: S }>> {
|
||||
const id =
|
||||
typeof args.where.id === "string" ? args.where.id : (args.where.friendlyId ?? undefined);
|
||||
// Never forward a control-plane tx to NEW (it would update the wrong DB and the row would
|
||||
// not be found); forward tx only to LEGACY (same physical DB as the tx). Mirrors #routeWaitpointWrite.
|
||||
// The caller's `tx` is never forwarded — the update runs on the owning store's own client so
|
||||
// it targets the DB the batch actually lives on. Mirrors #routeWaitpointWrite.
|
||||
const store = this.#routeOrNew(id);
|
||||
return store.updateBatchTaskRun(args, store === this.#legacy ? tx : undefined);
|
||||
return store.updateBatchTaskRun(args, undefined);
|
||||
}
|
||||
|
||||
// Batches can be written to either DB by different create paths (runEngine routes by id;
|
||||
@@ -1580,7 +1614,7 @@ export class RoutingRunStore implements RunStore {
|
||||
const id = RoutingRunStore.#scalarId(args.where);
|
||||
if (id !== undefined) {
|
||||
const store = this.#routeOrNew(id);
|
||||
return store.updateManyBatchTaskRun(args, store === this.#legacy ? tx : undefined);
|
||||
return store.updateManyBatchTaskRun(args, undefined);
|
||||
}
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
this.#new.updateManyBatchTaskRun(args),
|
||||
@@ -1613,7 +1647,7 @@ export class RoutingRunStore implements RunStore {
|
||||
RoutingRunStore.#scalarId(args.where);
|
||||
if (id !== undefined) {
|
||||
const store = this.#routeOrNew(id);
|
||||
return store.updateManyBatchTaskRunItems(args, store === this.#legacy ? tx : undefined);
|
||||
return store.updateManyBatchTaskRunItems(args, undefined);
|
||||
}
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
this.#new.updateManyBatchTaskRunItems(args),
|
||||
@@ -1622,6 +1656,86 @@ export class RoutingRunStore implements RunStore {
|
||||
return { count: fromNew.count + fromLegacy.count };
|
||||
}
|
||||
|
||||
// An item co-resides with its batch AND its child run on one DB (both FKs local), so route by
|
||||
// batchTaskRunId (residency-encoding) first, else by taskRunId — both classify to the same store.
|
||||
// Never forward the caller's client verbatim; its presence resolves to the owning store's OWN primary.
|
||||
findManyBatchTaskRunItems<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { taskRunId?: string; batchTaskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }>[]> {
|
||||
if (where.batchTaskRunId === undefined && where.taskRunId === undefined) {
|
||||
throw new Error("findManyBatchTaskRunItems requires batchTaskRunId or taskRunId to route");
|
||||
}
|
||||
const store = this.#routeOrNew(where.batchTaskRunId ?? where.taskRunId);
|
||||
return store.findManyBatchTaskRunItems(where, args, RoutingRunStore.#ownPrimary(store, client));
|
||||
}
|
||||
|
||||
// Route by batchTaskRunId (the item co-resides with its batch). Never forward the caller's client
|
||||
// verbatim; its presence resolves to the owning store's OWN primary.
|
||||
findBatchTaskRunItem<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { batchTaskRunId: string; taskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }> | null> {
|
||||
const store = this.#routeOrNew(where.batchTaskRunId);
|
||||
return store.findBatchTaskRunItem(where, args, RoutingRunStore.#ownPrimary(store, client));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WaitpointTag — a standalone entity (no run/waitpoint FK) keyed by (environmentId, name).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Callers never mint a tag id (defaults to cuid), so #routeWaitpointWrite always resolves LEGACY
|
||||
// today — deliberately single-homed, like standalone waitpoint tokens. If tag-id minting is ever made
|
||||
// residency-aware, findManyWaitpointTags must de-dupe by (environmentId, name) or names will duplicate.
|
||||
upsertWaitpointTag(
|
||||
data: { environmentId: string; name: string; projectId: string; id?: string },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<WaitpointTag> {
|
||||
const { store, tx: routedTx } = this.#routeWaitpointWrite(data.id, tx);
|
||||
return store.upsertWaitpointTag(data, routedTx);
|
||||
}
|
||||
|
||||
// A tag keyed by (environmentId, name) can exist on BOTH DBs for one env (dual-resident, no
|
||||
// id-shape signal), so fan out NEW→LEGACY and de-dupe by id (NEW wins, matching the router's
|
||||
// NEW-wins invariant). take/skip are widened per-leg then re-imposed globally after the merge,
|
||||
// mirroring the run-list open-predicate fan-out (#findRunsOpen + finalizeRows).
|
||||
async findManyWaitpointTags(
|
||||
args: {
|
||||
where: Prisma.WaitpointTagWhereInput;
|
||||
orderBy?:
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput[];
|
||||
take?: number;
|
||||
skip?: number;
|
||||
},
|
||||
client?: ReadClient
|
||||
): Promise<WaitpointTag[]> {
|
||||
const skip = args.skip ?? 0;
|
||||
// Each leg must return enough rows for the post-merge slice: drop skip per-leg (re-imposed
|
||||
// globally below) and, when bounded, widen take to skip+take.
|
||||
const perLeg = {
|
||||
...args,
|
||||
skip: 0,
|
||||
...(args.take != null ? { take: skip + args.take } : {}),
|
||||
};
|
||||
const [fromNew, fromLegacy] = await Promise.all([
|
||||
this.#new.findManyWaitpointTags(perLeg, RoutingRunStore.#ownPrimary(this.#new, client)),
|
||||
this.#legacy.findManyWaitpointTags(perLeg, RoutingRunStore.#ownPrimary(this.#legacy, client)),
|
||||
]);
|
||||
const byId = new Map<string, WaitpointTag>();
|
||||
for (const tag of fromLegacy) byId.set(tag.id, tag);
|
||||
for (const tag of fromNew) byId.set(tag.id, tag);
|
||||
const merged = args.orderBy
|
||||
? (sortByOrderBy(
|
||||
[...byId.values()] as unknown as Array<Record<string, unknown>>,
|
||||
args.orderBy as unknown as NonNullable<FindRunsArgs["orderBy"]>
|
||||
) as unknown as WaitpointTag[])
|
||||
: [...byId.values()];
|
||||
return merged.slice(skip, args.take != null ? skip + args.take : undefined);
|
||||
}
|
||||
|
||||
// Extract a scalar string `id` from a `{ id }` / `{ id: { equals } }` where; undefined otherwise.
|
||||
static #scalarId(where: unknown): string | undefined {
|
||||
return RoutingRunStore.#scalarField(where, "id");
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TaskRunExecutionStatus,
|
||||
RuntimeEnvironmentType,
|
||||
Waitpoint,
|
||||
WaitpointTag,
|
||||
} from "@trigger.dev/database";
|
||||
import type { TaskRunError } from "@trigger.dev/core/v3/schemas";
|
||||
import type { Residency } from "@trigger.dev/core/v3/isomorphic";
|
||||
@@ -233,6 +234,20 @@ export type RewriteDebouncedRunData = {
|
||||
runTags?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Input for {@link RunStore.finalizeRun}: the terminal `status` and its `error` are written in ONE
|
||||
* update (a separate later error write races realtime, which shuts the stream on the final status
|
||||
* before the error lands). `bulkActionId` is pushed onto `bulkActionGroupIds`. Every field is
|
||||
* optional so a caller can finalize with any subset (e.g. status-only, or expire with expiredAt).
|
||||
*/
|
||||
export type FinalizeRunData = {
|
||||
status?: TaskRunStatus;
|
||||
expiredAt?: Date;
|
||||
completedAt?: Date;
|
||||
error?: TaskRunError;
|
||||
bulkActionId?: string;
|
||||
};
|
||||
|
||||
export type ClearIdempotencyKeyInput =
|
||||
| { byId: { runId: string; idempotencyKey: string }; byPredicate?: never; byFriendlyIds?: never }
|
||||
| {
|
||||
@@ -394,6 +409,27 @@ export interface RunStore {
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ select: S }>>;
|
||||
|
||||
// Generic dual-residency finalize: writes the terminal `status` and its `error` in ONE update,
|
||||
// pushing `bulkActionId` onto `bulkActionGroupIds`. Overloads mirror findRun: select / include /
|
||||
// bare full-row.
|
||||
finalizeRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { select: S },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ select: S }>>;
|
||||
finalizeRun<I extends Prisma.TaskRunInclude>(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
args: { include: I },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.TaskRunGetPayload<{ include: I }>>;
|
||||
finalizeRun(
|
||||
runId: string,
|
||||
data: FinalizeRunData,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<TaskRun>;
|
||||
|
||||
// Expiry
|
||||
expireRun<S extends Prisma.TaskRunSelect>(
|
||||
runId: string,
|
||||
@@ -768,4 +804,38 @@ export interface RunStore {
|
||||
args: Prisma.BatchTaskRunItemUpdateManyArgs,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<Prisma.BatchPayload>;
|
||||
// An item co-resides with both its batch (batchTaskRunId FK) and its child run (taskRunId FK) on
|
||||
// ONE DB, so a read keyed by either scalar routes to that store; `include` resolves the co-resident
|
||||
// relations locally.
|
||||
findManyBatchTaskRunItems<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { taskRunId?: string; batchTaskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }>[]>;
|
||||
findBatchTaskRunItem<I extends Prisma.BatchTaskRunItemInclude = {}>(
|
||||
where: { batchTaskRunId: string; taskRunId?: string },
|
||||
args?: { include?: I },
|
||||
client?: ReadClient
|
||||
): Promise<Prisma.BatchTaskRunItemGetPayload<{ include: I }> | null>;
|
||||
|
||||
// --- WaitpointTag (run-ops) ---
|
||||
// A WaitpointTag has no run/waitpoint FK — a standalone entity keyed by (environmentId, name).
|
||||
// Callers never mint a tag id (defaults to cuid), so the WRITE is always LEGACY-resident today
|
||||
// (single-homed), like standalone waitpoint tokens; the READ still fans out NEW→LEGACY and
|
||||
// de-dupes by id in case tag ids ever become residency-aware.
|
||||
upsertWaitpointTag(
|
||||
data: { environmentId: string; name: string; projectId: string; id?: string },
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<WaitpointTag>;
|
||||
findManyWaitpointTags(
|
||||
args: {
|
||||
where: Prisma.WaitpointTagWhereInput;
|
||||
orderBy?:
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput
|
||||
| Prisma.WaitpointTagOrderByWithRelationInput[];
|
||||
take?: number;
|
||||
skip?: number;
|
||||
},
|
||||
client?: ReadClient
|
||||
): Promise<WaitpointTag[]>;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,84 @@ export const heteroRunOpsPostgresTest = test.extend<HeteroRunOpsPostgresTestCont
|
||||
},
|
||||
});
|
||||
|
||||
type ThreeDbRunOpsPostgresTestContext = {
|
||||
// Control-plane DB — full @trigger.dev/database schema.
|
||||
controlPlanePrisma: PrismaClient;
|
||||
controlPlaneUri: string;
|
||||
// Legacy runs DB — full @trigger.dev/database schema, its OWN physical database (a separate clone),
|
||||
// proving Track 2's independent legacy client is no longer an alias of the control-plane DB.
|
||||
legacyPrisma: PrismaClient;
|
||||
legacyUri: string;
|
||||
// New runs DB — the @internal/run-ops-database SUBSET schema on a separate PG17 container.
|
||||
newPrisma: RunOpsPrismaClient;
|
||||
newUri: string;
|
||||
};
|
||||
|
||||
// Track 2 three-database topology: control-plane, legacy, and new are three DISTINCT physical
|
||||
// databases. Control-plane and legacy both carry the full control-plane schema (two separate clones of
|
||||
// the PG14 template), new carries the dedicated run-ops subset (a clone of the PG17 run-ops template).
|
||||
// Lets a test prove routed run reads/writes land on the LEGACY DB (cuid) vs the NEW DB (run-ops id)
|
||||
// while control-plane-model access stays on its own DB.
|
||||
export const threeDbRunOpsPostgresTest = test.extend<ThreeDbRunOpsPostgresTestContext>({
|
||||
controlPlaneUri: async ({}, use) => {
|
||||
const container = await getWorkerPostgresContainer();
|
||||
const baseUri = container.getConnectionUri();
|
||||
const cloneDb = `threeDbCp_${pgCloneCounter++}`;
|
||||
await createDatabaseFromTemplate(baseUri, cloneDb);
|
||||
try {
|
||||
await use(postgresUriWithDatabase(baseUri, cloneDb));
|
||||
} finally {
|
||||
await dropCloneDatabase(baseUri, cloneDb);
|
||||
}
|
||||
},
|
||||
legacyUri: async ({}, use) => {
|
||||
const container = await getWorkerPostgresContainer();
|
||||
const baseUri = container.getConnectionUri();
|
||||
const cloneDb = `threeDbLegacy_${pgCloneCounter++}`;
|
||||
await createDatabaseFromTemplate(baseUri, cloneDb);
|
||||
try {
|
||||
await use(postgresUriWithDatabase(baseUri, cloneDb));
|
||||
} finally {
|
||||
await dropCloneDatabase(baseUri, cloneDb);
|
||||
}
|
||||
},
|
||||
newUri: async ({}, use) => {
|
||||
const container = await getRunOpsWorkerPostgresContainer17();
|
||||
const baseUri = container.getConnectionUri();
|
||||
const cloneDb = `threeDbNew_${pgCloneCounter++}`;
|
||||
await createDatabaseFromTemplate(baseUri, cloneDb);
|
||||
try {
|
||||
await use(postgresUriWithDatabase(baseUri, cloneDb));
|
||||
} finally {
|
||||
await dropCloneDatabase(baseUri, cloneDb);
|
||||
}
|
||||
},
|
||||
controlPlanePrisma: async ({ controlPlaneUri }, use) => {
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: controlPlaneUri } } });
|
||||
try {
|
||||
await use(prisma);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
},
|
||||
legacyPrisma: async ({ legacyUri }, use) => {
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: legacyUri } } });
|
||||
try {
|
||||
await use(prisma);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
},
|
||||
newPrisma: async ({ newUri }, use) => {
|
||||
const prisma = new RunOpsPrismaClient({ datasources: { db: { url: newUri } } });
|
||||
try {
|
||||
await use(prisma);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const redisContainer = async (
|
||||
{ network, task }: { network: StartedNetwork } & TestContext,
|
||||
use: Use<StartedRedisContainer>
|
||||
|
||||
Reference in New Issue
Block a user