feat(run-ops): activation — drop cross-DB FKs, provision run-ops DB, enable split (#4124)

This commit is contained in:
Daniel Sutton
2026-07-04 07:02:28 +01:00
committed by GitHub
parent 1f4369d092
commit 70bca82d84
13 changed files with 1109 additions and 19 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Enable the dedicated run-ops database split: run records and their related rows are served from a separate database, with cross-database references resolved in application code instead of database foreign keys.
@@ -10,7 +10,6 @@ export class PerformBulkActionService extends BaseService {
const item = await this._prisma.bulkActionItem.findFirst({
where: { id: bulkActionItemId },
include: {
group: true,
sourceRun: true,
destinationRun: true,
},
@@ -24,7 +23,7 @@ export class PerformBulkActionService extends BaseService {
return;
}
switch (item.group.type) {
switch (item.type) {
case "REPLAY": {
const service = new ReplayTaskRunService(this._prisma);
const result = await service.call(item.sourceRun, { triggerSource: "dashboard" });
@@ -57,7 +56,7 @@ export class PerformBulkActionService extends BaseService {
break;
}
default: {
assertNever(item.group.type);
assertNever(item.type);
}
}
@@ -94,17 +93,20 @@ export class PerformBulkActionService extends BaseService {
public async call(bulkActionGroupId: string) {
const actionGroup = await this._prisma.bulkActionGroup.findFirst({
include: {
items: true,
},
where: { id: bulkActionGroupId },
select: { id: true },
});
if (!actionGroup) {
return;
}
for (const item of actionGroup.items) {
const items = await this._prisma.bulkActionItem.findMany({
where: { groupId: bulkActionGroupId },
select: { id: true },
});
for (const item of items) {
await this.enqueueBulkActionItem(item.id, bulkActionGroupId);
}
}
@@ -0,0 +1,169 @@
// Proof for dropping the canonical BatchTaskRun -> RuntimeEnvironment FK
// (constraint "BatchTaskRun_runtimeEnvironmentId_fkey", onDelete: Cascade) while keeping the
// runtimeEnvironmentId scalar and its compound @@unique/@@index. BatchTaskRun is run-ops and
// RuntimeEnvironment is control-plane, so the two may live on different servers; create-time
// integrity is preserved app-side via the ControlPlaneResolver's assertEnvExists. Env-delete
// orphan cleanup is handled separately — here the batch row is tolerated.
import { heteroPostgresTest, postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { ControlPlaneCache } from "~/v3/runOpsMigration/controlPlaneCache.server";
import {
ControlPlaneReferenceError,
ControlPlaneResolver,
} from "~/v3/runOpsMigration/controlPlaneResolver.server";
// Cross-DB testcontainer spin-up + queries can exceed the 5s default on the first test.
vi.setConfig({ testTimeout: 60_000 });
let seedCounter = 0;
async function seedEnvironment(prisma: PrismaClient) {
const n = seedCounter++;
const organization = await prisma.organization.create({
data: { title: `Org ${n}`, slug: `org-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${n}`,
slug: `project-${n}`,
externalRef: `proj_${n}`,
organizationId: organization.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${n}`,
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${n}`,
pkApiKey: `pk_prod_${n}`,
shortcode: `short_${n}`,
},
});
return { organization, project, environment };
}
let batchCounter = 0;
async function createBatch(prisma: PrismaClient, runtimeEnvironmentId: string) {
const n = batchCounter++;
return prisma.batchTaskRun.create({
data: {
friendlyId: `batch_${n}`,
runtimeEnvironmentId,
runCount: 1,
runIds: [],
batchVersion: "runengine:v2",
},
});
}
// Asserts the post-migration state of BatchTaskRun on a given client: the FK is gone, but the
// scalar and both compound constraints are retained. Shared by the single-version and the
// cross-version suites.
async function assertSchemaState(prisma: PrismaClient) {
const foreignKeys = await prisma.$queryRaw<{ constraint_name: string }[]>`
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'BatchTaskRun'
AND constraint_type = 'FOREIGN KEY'
`;
expect(foreignKeys.map((c) => c.constraint_name)).not.toContain(
"BatchTaskRun_runtimeEnvironmentId_fkey"
);
const columns = await prisma.$queryRaw<{ column_name: string }[]>`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'BatchTaskRun'
AND column_name = 'runtimeEnvironmentId'
`;
expect(columns).toHaveLength(1);
// The @@unique([runtimeEnvironmentId, idempotencyKey]) and
// @@index([runtimeEnvironmentId, id(sort: Desc)]) both survive the FK drop.
const indexes = await prisma.$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes WHERE tablename = 'BatchTaskRun'
`;
const defs = indexes.map((i) => i.indexdef);
const hasUnique = defs.some(
(d) => /UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && d.includes("idempotencyKey")
);
const hasIndex = defs.some(
(d) => !/UNIQUE/i.test(d) && d.includes("runtimeEnvironmentId") && /\bid\b/.test(d)
);
expect(hasUnique).toBe(true);
expect(hasIndex).toBe(true);
}
// Inserts an env + batch, deletes the env, and asserts the batch survives (cascade gone).
async function assertOrphanTolerated(prisma: PrismaClient) {
const { environment } = await seedEnvironment(prisma);
const batch = await createBatch(prisma, environment.id);
await prisma.runtimeEnvironment.delete({ where: { id: environment.id } });
const survivor = await prisma.batchTaskRun.findFirst({ where: { id: batch.id } });
expect(survivor).not.toBeNull();
expect(survivor?.runtimeEnvironmentId).toBe(environment.id);
}
describe("drop BatchTaskRun -> RuntimeEnvironment FK", () => {
postgresTest("FK constraint absent; scalar + unique + index retained", async ({ prisma }) => {
await assertSchemaState(prisma);
});
postgresTest(
"deleting the env leaves the BatchTaskRun row alive (no cascade; orphan cleanup handled separately)",
async ({ prisma }) => {
await assertOrphanTolerated(prisma);
}
);
postgresTest(
"app-side env validation: assertEnvExists rejects an invalid env and a valid-env create succeeds by scalar",
async ({ prisma }) => {
const { environment } = await seedEnvironment(prisma);
const resolver = new ControlPlaneResolver({
controlPlanePrimary: prisma,
controlPlaneReplica: prisma,
cache: new ControlPlaneCache(),
splitEnabled: () => true,
});
// The exact guard call the create services place before batchTaskRun.create.
await expect(resolver.assertEnvExists("env_does_not_exist")).rejects.toBeInstanceOf(
ControlPlaneReferenceError
);
await expect(resolver.assertEnvExists(environment.id)).resolves.toBeUndefined();
// Once the guard passes, the batch is linked by the runtimeEnvironmentId scalar (no FK).
const batch = await createBatch(prisma, environment.id);
expect(batch.runtimeEnvironmentId).toBe(environment.id);
}
);
});
// Cross-version gate: the migration applies and the post-state is identical across major versions.
describe("drop BatchTaskRun -> RuntimeEnvironment FK — cross-version (legacy + new Postgres)", () => {
heteroPostgresTest(
"migration applies and FK is absent on both the legacy and new databases",
async ({ prisma14, prisma17 }) => {
await assertSchemaState(prisma14);
await assertSchemaState(prisma17);
}
);
heteroPostgresTest(
"env delete leaves the batch orphaned on both the legacy and new databases",
async ({ prisma14, prisma17 }) => {
await assertOrphanTolerated(prisma14);
await assertOrphanTolerated(prisma17);
}
);
});
@@ -0,0 +1,64 @@
// Single-version proof for dropping the dead `_TaskRunToTaskRunTag` implicit join.
import { describe, expect } from "vitest";
import { postgresTest } from "@internal/testcontainers";
describe("drop _TaskRunToTaskRunTag implicit join", () => {
postgresTest("runTags scalar round-trips and the join table is gone", async ({ prisma }) => {
const organization = await prisma.organization.create({
data: {
title: "test",
slug: "test",
},
});
const project = await prisma.project.create({
data: {
name: "test",
slug: "test",
organizationId: organization.id,
externalRef: "test",
},
});
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
data: {
slug: "test",
type: "DEVELOPMENT",
projectId: project.id,
organizationId: organization.id,
apiKey: "test",
pkApiKey: "test",
shortcode: "test",
},
});
const taskRun = await prisma.taskRun.create({
data: {
friendlyId: "run_1234",
taskIdentifier: "my-task",
payload: JSON.stringify({ foo: "bar" }),
payloadType: "application/json",
traceId: "1234",
spanId: "1234",
queue: "test",
runtimeEnvironmentId: runtimeEnvironment.id,
projectId: project.id,
organizationId: organization.id,
environmentType: "DEVELOPMENT",
engine: "V2",
runTags: ["alpha", "beta"],
},
});
const readBack = await prisma.taskRun.findFirstOrThrow({
where: { id: taskRun.id },
});
expect(readBack.runTags).toEqual(["alpha", "beta"]);
const result = await prisma.$queryRaw<{ t: string | null }[]>`
SELECT to_regclass('public._TaskRunToTaskRunTag')::text as t
`;
expect(result[0].t).toBeNull();
});
});
@@ -0,0 +1,660 @@
import { heteroPostgresTest, heteroRunOpsPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import { describe, expect, vi } from "vitest";
import { RunOpsCascadeCleanupService } from "~/v3/runOpsMigration/runOpsCascadeCleanup.server";
// Cross-DB testcontainer spin-up + the multi-table seed can exceed the 5s default.
vi.setConfig({ testTimeout: 120_000 });
// Run-subgraph tables that live in BOTH the control-plane schema AND the dedicated run-ops SUBSET
// schema, so they are deleted on every run-ops writer.
const SUBGRAPH_TABLES = [
"taskRun",
"taskRunAttempt",
"waitpoint",
"taskRunWaitpoint",
"taskRunCheckpoint",
"checkpoint",
"checkpointRestoreEvent",
"batchTaskRun",
] as const;
type SubgraphTable = (typeof SUBGRAPH_TABLES)[number];
let seedCounter = 0;
/**
* The cross-seam (run-ops -> control-plane) Cascade FKs that the cloud DB physically drops. Applied
* to the FK-dropped fixture to model cloud; the other side keeps them to model self-host. Only the
* run-subgraph constraints exist on the dedicated run-ops schema; BulkActionItem's are control-plane
* only and are dropped separately on a full-schema client.
*/
const SUBGRAPH_CROSS_SEAM_FKS: Array<{ table: string; constraint: string }> = [
{ table: "TaskRun", constraint: "TaskRun_runtimeEnvironmentId_fkey" },
{ table: "TaskRun", constraint: "TaskRun_projectId_fkey" },
{ table: "TaskRunAttempt", constraint: "TaskRunAttempt_runtimeEnvironmentId_fkey" },
{ table: "Waitpoint", constraint: "Waitpoint_environmentId_fkey" },
{ table: "Waitpoint", constraint: "Waitpoint_projectId_fkey" },
{ table: "TaskRunWaitpoint", constraint: "TaskRunWaitpoint_projectId_fkey" },
{ table: "TaskRunCheckpoint", constraint: "TaskRunCheckpoint_runtimeEnvironmentId_fkey" },
{ table: "TaskRunCheckpoint", constraint: "TaskRunCheckpoint_projectId_fkey" },
{ table: "Checkpoint", constraint: "Checkpoint_runtimeEnvironmentId_fkey" },
{ table: "Checkpoint", constraint: "Checkpoint_projectId_fkey" },
{
table: "CheckpointRestoreEvent",
constraint: "CheckpointRestoreEvent_runtimeEnvironmentId_fkey",
},
{ table: "CheckpointRestoreEvent", constraint: "CheckpointRestoreEvent_projectId_fkey" },
{ table: "BatchTaskRun", constraint: "BatchTaskRun_runtimeEnvironmentId_fkey" },
];
const BULK_ACTION_CROSS_SEAM_FKS: Array<{ table: string; constraint: string }> = [
{ table: "BulkActionItem", constraint: "BulkActionItem_sourceRunId_fkey" },
{ table: "BulkActionItem", constraint: "BulkActionItem_destinationRunId_fkey" },
];
async function dropCrossSeamFks(
prisma: { $executeRawUnsafe: (q: string) => Promise<unknown> },
fks: Array<{ table: string; constraint: string }>
) {
for (const { table, constraint } of fks) {
await prisma.$executeRawUnsafe(
`ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS "${constraint}"`
);
}
}
type Scope = { projectId: string; environmentId: string; organizationId: string };
type FullScope = Scope & { workerTaskId: string; queueId: string; backgroundWorkerId: string };
// Minimal structural client covering the control-plane prerequisites + run-subgraph models the
// seed/count helpers touch. Both PrismaClient and RunOpsPrismaClient are assignable.
type SeedClient = {
organization: any;
project: any;
runtimeEnvironment: any;
backgroundWorker: any;
backgroundWorkerTask: any;
taskQueue: any;
taskRun: any;
taskRunAttempt: any;
waitpoint: any;
taskRunWaitpoint: any;
taskRunCheckpoint: any;
checkpoint: any;
checkpointRestoreEvent: any;
batchTaskRun: any;
};
// Synthetic scope for the dedicated run-ops subset client, whose schema scalarizes every
// control-plane FK so no org/project/env rows are required.
function makeSyntheticScope(): FullScope {
const n = seedCounter++;
return {
projectId: `proj_synthetic_${n}`,
environmentId: `env_synthetic_${n}`,
organizationId: `org_synthetic_${n}`,
workerTaskId: `task_synthetic_${n}`,
queueId: `queue_synthetic_${n}`,
backgroundWorkerId: `worker_synthetic_${n}`,
};
}
/** Create the control-plane prerequisites (org, project, env, worker, task, queue). */
async function seedScope(prisma: SeedClient): Promise<FullScope> {
const n = seedCounter++;
const org = await prisma.organization.create({
data: { title: `Org ${n}`, slug: `org-${n}` },
});
const project = await prisma.project.create({
data: {
name: `Project ${n}`,
slug: `project-${n}`,
externalRef: `proj_${n}`,
organizationId: org.id,
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: `env-${n}`,
projectId: project.id,
organizationId: org.id,
apiKey: `tr_prod_${n}`,
pkApiKey: `pk_prod_${n}`,
shortcode: `short_${n}`,
},
});
const worker = await prisma.backgroundWorker.create({
data: {
friendlyId: `worker_${n}`,
contentHash: `hash_${n}`,
projectId: project.id,
runtimeEnvironmentId: environment.id,
version: `2024.1.${n}`,
metadata: {},
engine: "V2",
},
});
const task = await prisma.backgroundWorkerTask.create({
data: {
friendlyId: `task_${n}`,
slug: `my-task-${n}`,
filePath: "index.ts",
exportName: "myTask",
workerId: worker.id,
runtimeEnvironmentId: environment.id,
projectId: project.id,
},
});
const queue = await prisma.taskQueue.create({
data: {
friendlyId: `queue_${n}`,
name: `task/my-task-${n}`,
runtimeEnvironmentId: environment.id,
projectId: project.id,
},
});
return {
projectId: project.id,
environmentId: environment.id,
organizationId: org.id,
workerTaskId: task.id,
queueId: queue.id,
backgroundWorkerId: worker.id,
};
}
/**
* Seed one full run-ops subgraph for a scope: a TaskRun tree (root + child), an attempt, a
* Waitpoint with a blocking edge (TaskRunWaitpoint), a TaskRunCheckpoint, a Checkpoint + a
* CheckpointRestoreEvent, and a BatchTaskRun with a member run. Returns the source + destination
* runs so a caller with a control-plane client can attach a BulkActionItem.
*/
async function seedRunOpsSubgraph(
prisma: SeedClient,
scope: Scope & { backgroundWorkerId: string; workerTaskId: string; queueId: string }
): Promise<{ sourceRunId: string; destinationRunId: string }> {
const n = seedCounter++;
const { projectId, environmentId } = scope;
const baseRun = (suffix: string) => ({
friendlyId: `run_${n}_${suffix}`,
taskIdentifier: `my-task-${n}`,
payload: "{}",
payloadType: "application/json",
traceId: `trace_${n}_${suffix}`,
spanId: `span_${n}_${suffix}`,
queue: `task/my-task-${n}`,
runtimeEnvironmentId: environmentId,
projectId,
});
const rootRun = await prisma.taskRun.create({ data: baseRun("root") });
const childRun = await prisma.taskRun.create({
data: { ...baseRun("child"), parentTaskRunId: rootRun.id, rootTaskRunId: rootRun.id },
});
const attempt = await prisma.taskRunAttempt.create({
data: {
friendlyId: `attempt_${n}`,
taskRunId: rootRun.id,
backgroundWorkerId: scope.backgroundWorkerId,
backgroundWorkerTaskId: scope.workerTaskId,
runtimeEnvironmentId: environmentId,
queueId: scope.queueId,
},
});
const waitpoint = await prisma.waitpoint.create({
data: {
friendlyId: `wp_${n}`,
type: "MANUAL",
idempotencyKey: `wp_idem_${n}`,
userProvidedIdempotencyKey: false,
environmentId,
projectId,
},
});
await prisma.taskRunWaitpoint.create({
data: { taskRunId: rootRun.id, waitpointId: waitpoint.id, projectId },
});
await prisma.taskRunCheckpoint.create({
data: {
friendlyId: `trcp_${n}`,
type: "DOCKER",
location: "loc",
runtimeEnvironmentId: environmentId,
projectId,
},
});
const checkpoint = await prisma.checkpoint.create({
data: {
friendlyId: `cp_${n}`,
type: "DOCKER",
location: "loc",
imageRef: "ref",
runId: rootRun.id,
attemptId: attempt.id,
runtimeEnvironmentId: environmentId,
projectId,
},
});
await prisma.checkpointRestoreEvent.create({
data: {
type: "CHECKPOINT",
checkpointId: checkpoint.id,
runId: rootRun.id,
attemptId: attempt.id,
runtimeEnvironmentId: environmentId,
projectId,
},
});
const batch = await prisma.batchTaskRun.create({
data: { friendlyId: `batch_${n}`, runtimeEnvironmentId: environmentId },
});
await prisma.taskRun.update({ where: { id: childRun.id }, data: { batchId: batch.id } });
const sourceRun = await prisma.taskRun.create({ data: baseRun("src") });
const destRun = await prisma.taskRun.create({ data: baseRun("dst") });
return { sourceRunId: sourceRun.id, destinationRunId: destRun.id };
}
/** Attach a BulkActionItem (control-plane-resident) over the given source/destination runs. */
async function seedBulkActionItem(
prisma: PrismaClient,
runs: { sourceRunId: string; destinationRunId: string }
): Promise<void> {
await prisma.bulkActionItem.create({
data: {
groupId: `grp_${seedCounter++}`,
type: "REPLAY",
sourceRunId: runs.sourceRunId,
destinationRunId: runs.destinationRunId,
},
});
}
async function subgraphCountsForScope(
prisma: SeedClient,
scope: { projectId: string }
): Promise<Record<SubgraphTable, number>> {
const { projectId } = scope;
return {
taskRun: await prisma.taskRun.count({ where: { projectId } }),
taskRunAttempt: await prisma.taskRunAttempt.count({ where: { taskRun: { projectId } } }),
waitpoint: await prisma.waitpoint.count({ where: { projectId } }),
taskRunWaitpoint: await prisma.taskRunWaitpoint.count({ where: { projectId } }),
taskRunCheckpoint: await prisma.taskRunCheckpoint.count({ where: { projectId } }),
checkpoint: await prisma.checkpoint.count({ where: { projectId } }),
checkpointRestoreEvent: await prisma.checkpointRestoreEvent.count({ where: { projectId } }),
batchTaskRun: await prisma.batchTaskRun.count({
where: { runs: { some: { projectId } } },
}),
};
}
async function bulkActionItemCountForScope(
prisma: PrismaClient,
scope: { projectId: string }
): Promise<number> {
return prisma.bulkActionItem.count({ where: { sourceRun: { projectId: scope.projectId } } });
}
function expectSubgraphAllZero(counts: Record<SubgraphTable, number>) {
for (const table of SUBGRAPH_TABLES) {
expect(counts[table], `${table} should be empty`).toBe(0);
}
}
function expectSubgraphAllNonZero(counts: Record<SubgraphTable, number>) {
for (const table of SUBGRAPH_TABLES) {
expect(counts[table], `${table} should be seeded`).toBeGreaterThan(0);
}
}
describe("RunOpsCascadeCleanupService", () => {
// REGRESSION: the NEW run-ops writer is a real RunOpsPrismaClient over the dedicated
// SUBSET schema — it has NO `bulkActionItem` delegate. Before the fix, the per-writer pass called
// `writer.bulkActionItem.deleteMany` on this client => TypeError (Cannot read properties of
// undefined). After the fix, BulkActionItem is cleaned ONLY on the control-plane writer (prisma14),
// and the run-subgraph is deleted on the NEW DB without throwing.
heteroRunOpsPostgresTest(
"cleanupProject does not throw on the dedicated RunOpsPrismaClient and clears the new DB subgraph",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma14, BULK_ACTION_CROSS_SEAM_FKS);
// The dedicated run-ops subset schema scalarizes every control-plane FK, so the NEW DB needs
// NO org/project/env prereqs — seed the subgraph directly with synthetic scope ids.
const newScope = makeSyntheticScope();
await seedRunOpsSubgraph(prisma17 as unknown as SeedClient, newScope);
// BulkActionItem (control-plane-resident) lives only on the control-plane DB.
const cp = await seedScope(prisma14);
const cpRuns = await seedRunOpsSubgraph(prisma14, cp);
await seedBulkActionItem(prisma14, cpRuns);
// prisma17 is a real RunOpsPrismaClient (subset, no bulkActionItem delegate); prisma14 is the
// control-plane writer. Before the fix this threw a TypeError on writer.bulkActionItem.
const result = await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14 as unknown as RunOpsPrismaClient],
controlPlaneWriter: prisma14,
}).cleanupProject(newScope.projectId);
expectSubgraphAllZero(
await subgraphCountsForScope(prisma17 as unknown as SeedClient, newScope)
);
// BulkActionItem cleanup ran against the control-plane writer and deleted the control-plane
// project's item; the subset client was never asked for the missing delegate.
expect(result.bulkActionItem).toBeGreaterThanOrEqual(0);
}
);
// REGRESSION (env variant): same guarantee for cleanupEnvironment.
heteroRunOpsPostgresTest(
"cleanupEnvironment does not throw on the dedicated RunOpsPrismaClient and clears the new DB subgraph",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma14, BULK_ACTION_CROSS_SEAM_FKS);
const newScope = makeSyntheticScope();
await seedRunOpsSubgraph(prisma17 as unknown as SeedClient, newScope);
const cp = await seedScope(prisma14);
const cpRuns = await seedRunOpsSubgraph(prisma14, cp);
await seedBulkActionItem(prisma14, cpRuns);
const result = await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14 as unknown as RunOpsPrismaClient],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(newScope.environmentId);
expectSubgraphAllZero(
await subgraphCountsForScope(prisma17 as unknown as SeedClient, newScope)
);
expect(result.bulkActionItem).toBeGreaterThanOrEqual(0);
}
);
// Env cleanup over both writers empties the subgraph on BOTH DBs + BulkActionItem on the
// control-plane DB; a sibling scope survives.
heteroPostgresTest(
"cleanupEnvironment empties the subgraph across both writers, isolating a sibling env",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma17, SUBGRAPH_CROSS_SEAM_FKS);
await dropCrossSeamFks(prisma17, BULK_ACTION_CROSS_SEAM_FKS);
const target14 = await seedScope(prisma14);
const target17 = await seedScope(prisma17);
const targetRuns14 = await seedRunOpsSubgraph(prisma14, target14);
await seedRunOpsSubgraph(prisma17, target17);
await seedBulkActionItem(prisma14, targetRuns14);
const sibling14 = await seedScope(prisma14);
const sibling17 = await seedScope(prisma17);
const siblingRuns14 = await seedRunOpsSubgraph(prisma14, sibling14);
await seedRunOpsSubgraph(prisma17, sibling17);
await seedBulkActionItem(prisma14, siblingRuns14);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(target14.environmentId);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(target17.environmentId);
expectSubgraphAllZero(await subgraphCountsForScope(prisma14, target14));
expectSubgraphAllZero(await subgraphCountsForScope(prisma17, target17));
expect(await bulkActionItemCountForScope(prisma14, target14)).toBe(0);
expectSubgraphAllNonZero(await subgraphCountsForScope(prisma14, sibling14));
expectSubgraphAllNonZero(await subgraphCountsForScope(prisma17, sibling17));
expect(await bulkActionItemCountForScope(prisma14, sibling14)).toBeGreaterThan(0);
}
);
// Project cleanup over both writers.
heteroPostgresTest(
"cleanupProject empties the subgraph across both writers, isolating a sibling project",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma17, SUBGRAPH_CROSS_SEAM_FKS);
await dropCrossSeamFks(prisma17, BULK_ACTION_CROSS_SEAM_FKS);
const target14 = await seedScope(prisma14);
const target17 = await seedScope(prisma17);
const targetRuns14 = await seedRunOpsSubgraph(prisma14, target14);
await seedRunOpsSubgraph(prisma17, target17);
await seedBulkActionItem(prisma14, targetRuns14);
const sibling14 = await seedScope(prisma14);
const sibling17 = await seedScope(prisma17);
const siblingRuns14 = await seedRunOpsSubgraph(prisma14, sibling14);
await seedRunOpsSubgraph(prisma17, sibling17);
await seedBulkActionItem(prisma14, siblingRuns14);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14],
controlPlaneWriter: prisma14,
}).cleanupProject(target14.projectId);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14],
controlPlaneWriter: prisma14,
}).cleanupProject(target17.projectId);
expectSubgraphAllZero(await subgraphCountsForScope(prisma14, target14));
expectSubgraphAllZero(await subgraphCountsForScope(prisma17, target17));
expect(await bulkActionItemCountForScope(prisma14, target14)).toBe(0);
expectSubgraphAllNonZero(await subgraphCountsForScope(prisma14, sibling14));
expectSubgraphAllNonZero(await subgraphCountsForScope(prisma17, sibling17));
expect(await bulkActionItemCountForScope(prisma14, sibling14)).toBeGreaterThan(0);
}
);
// Idempotency — a second cleanup returns all-zero counts and does not throw on either DB.
heteroPostgresTest(
"cleanupEnvironment is idempotent on a re-run across both FK configs",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma17, SUBGRAPH_CROSS_SEAM_FKS);
await dropCrossSeamFks(prisma17, BULK_ACTION_CROSS_SEAM_FKS);
const t14 = await seedScope(prisma14);
const t17 = await seedScope(prisma17);
const runs14 = await seedRunOpsSubgraph(prisma14, t14);
await seedRunOpsSubgraph(prisma17, t17);
await seedBulkActionItem(prisma14, runs14);
const svc14 = new RunOpsCascadeCleanupService({
runOpsWriters: [prisma14],
controlPlaneWriter: prisma14,
});
const svc17 = new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17],
controlPlaneWriter: prisma14,
});
await svc14.cleanupEnvironment(t14.environmentId);
await svc17.cleanupEnvironment(t17.environmentId);
const second14 = await svc14.cleanupEnvironment(t14.environmentId);
const second17 = await svc17.cleanupEnvironment(t17.environmentId);
for (const result of [second14, second17]) {
for (const count of Object.values(result)) {
expect(count).toBe(0);
}
}
expectSubgraphAllZero(await subgraphCountsForScope(prisma14, t14));
expectSubgraphAllZero(await subgraphCountsForScope(prisma17, t17));
}
);
// FK-retained vs FK-dropped fixtures reach an identical run-subgraph end-state.
heteroPostgresTest(
"FK-retained and FK-dropped fixtures reach an identical end-state after cleanup",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma17, SUBGRAPH_CROSS_SEAM_FKS);
const s14 = await seedScope(prisma14);
const s17 = await seedScope(prisma17);
await seedRunOpsSubgraph(prisma14, s14);
await seedRunOpsSubgraph(prisma17, s17);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma14],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(s14.environmentId);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17],
controlPlaneWriter: prisma17,
}).cleanupEnvironment(s17.environmentId);
const counts14 = await subgraphCountsForScope(prisma14, s14);
const counts17 = await subgraphCountsForScope(prisma17, s17);
expect(counts17).toEqual(counts14);
}
);
// Single-DB mode — the same client passed twice de-dups so the pass runs once.
heteroPostgresTest(
"single-DB: the same client passed twice de-dups so the delete pass runs exactly once",
async ({ prisma14 }) => {
const scope = await seedScope(prisma14);
await seedRunOpsSubgraph(prisma14, scope);
const before = await subgraphCountsForScope(prisma14, scope);
// Wrap the real client with a $extends query hook that counts deleteMany calls per model. NOT
// a mock — the query still runs against the container. If de-dup failed, the loop would run
// twice against this same client and taskRun.deleteMany would fire twice.
let taskRunDeleteManyCalls = 0;
const counting = prisma14.$extends({
query: {
taskRun: {
async deleteMany({ args, query }) {
taskRunDeleteManyCalls++;
return query(args);
},
},
},
}) as unknown as typeof prisma14;
const result = await new RunOpsCascadeCleanupService({
runOpsWriters: [counting, counting],
controlPlaneWriter: counting,
}).cleanupEnvironment(scope.environmentId);
// De-dup ran the pass exactly once: one taskRun.deleteMany, count not double-summed.
expect(taskRunDeleteManyCalls).toBe(1);
expect(result.taskRun).toBe(before.taskRun);
expectSubgraphAllZero(await subgraphCountsForScope(prisma14, scope));
}
);
// The two-writer split — an env whose rows straddle both DBs (cuid runs on the LEGACY DB,
// ksuid runs on the NEW DB) is fully cleaned by one call; a single-writer service leaks orphans.
heteroPostgresTest(
"two-writer fan-out cleans a split env on both DBs; single-writer leaves orphans",
async ({ prisma14, prisma17 }) => {
await dropCrossSeamFks(prisma17, SUBGRAPH_CROSS_SEAM_FKS);
// One logical env that exists on both DBs (control-plane prereqs seeded on each), with the
// SAME env id, modelling the reference-equal control-plane row. We force a shared id
// by creating the legacy scope first, then mirroring its env id onto the new DB.
const legacy = await seedScope(prisma14);
const newOrg = await prisma17.organization.create({
data: { id: legacy.organizationId, title: "mirror", slug: `mirror-${seedCounter++}` },
});
const newProject = await prisma17.project.create({
data: {
id: legacy.projectId,
name: "mirror",
slug: `mirror-${seedCounter++}`,
externalRef: `mirror_${seedCounter++}`,
organizationId: newOrg.id,
},
});
const newEnv = await prisma17.runtimeEnvironment.create({
data: {
id: legacy.environmentId,
type: "PRODUCTION",
slug: `mirror-${seedCounter++}`,
projectId: newProject.id,
organizationId: newOrg.id,
apiKey: `tr_${seedCounter++}`,
pkApiKey: `pk_${seedCounter++}`,
shortcode: `sc_${seedCounter++}`,
},
});
const newWorker = await prisma17.backgroundWorker.create({
data: {
friendlyId: `w_${seedCounter++}`,
contentHash: "h",
projectId: newProject.id,
runtimeEnvironmentId: newEnv.id,
version: `2024.2.${seedCounter++}`,
metadata: {},
engine: "V2",
},
});
const newTask = await prisma17.backgroundWorkerTask.create({
data: {
friendlyId: `t_${seedCounter++}`,
slug: `s-${seedCounter++}`,
filePath: "index.ts",
exportName: "myTask",
workerId: newWorker.id,
runtimeEnvironmentId: newEnv.id,
projectId: newProject.id,
},
});
const newQueue = await prisma17.taskQueue.create({
data: {
friendlyId: `q_${seedCounter++}`,
name: `task/s-${seedCounter++}`,
runtimeEnvironmentId: newEnv.id,
projectId: newProject.id,
},
});
const newScope = {
projectId: newProject.id,
environmentId: newEnv.id,
organizationId: newOrg.id,
backgroundWorkerId: newWorker.id,
workerTaskId: newTask.id,
queueId: newQueue.id,
};
// Pre-cutover (LEGACY DB) and post-cutover (NEW DB) run-ops rows for the SAME env.
await seedRunOpsSubgraph(prisma14, legacy);
await seedRunOpsSubgraph(prisma17, newScope);
// Two-writer fan-out: one call cleans BOTH DBs.
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17, prisma14],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(legacy.environmentId);
expectSubgraphAllZero(await subgraphCountsForScope(prisma14, legacy));
expectSubgraphAllZero(await subgraphCountsForScope(prisma17, newScope));
// The orphan-leak guard: re-seed and run a mis-built SINGLE-writer service; it must leave the
// OTHER DB's rows behind.
await seedRunOpsSubgraph(prisma14, legacy);
await seedRunOpsSubgraph(prisma17, newScope);
await new RunOpsCascadeCleanupService({
runOpsWriters: [prisma17],
controlPlaneWriter: prisma14,
}).cleanupEnvironment(legacy.environmentId);
// NEW DB cleaned, LEGACY DB orphans remain — proving a one-handle delete leaks.
expectSubgraphAllZero(await subgraphCountsForScope(prisma17, newScope));
const leaked = await subgraphCountsForScope(prisma14, legacy);
expect(leaked.taskRun).toBeGreaterThan(0);
}
);
});
+5
View File
@@ -31,6 +31,8 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install
# Generate Prisma client here where all deps are installed
COPY --from=pruner --chown=node:node /triggerdotdev/internal-packages/database/prisma/schema.prisma /triggerdotdev/internal-packages/database/prisma/schema.prisma
RUN pnpx prisma@6.14.0 generate --schema /triggerdotdev/internal-packages/database/prisma/schema.prisma
COPY --from=pruner --chown=node:node /triggerdotdev/internal-packages/run-ops-database/prisma/schema.prisma /triggerdotdev/internal-packages/run-ops-database/prisma/schema.prisma
RUN pnpx prisma@6.14.0 generate --schema /triggerdotdev/internal-packages/run-ops-database/prisma/schema.prisma
## Production deps
FROM base AS production-deps
@@ -85,6 +87,9 @@ COPY --from=pruner --chown=node:node /triggerdotdev/out/full/ .
COPY --from=production-deps --chown=node:node /triggerdotdev .
# Copy generated Prisma client from dev-deps
COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/database/generated ./internal-packages/database/generated
# Run-ops Prisma client (query engine + client). Only constructed when the split is enabled,
# but the image must carry it so a split-on deployment doesn't hit a missing query engine.
COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/run-ops-database/generated ./internal-packages/run-ops-database/generated
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build/server.js ./apps/webapp/build/server.js
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build ./apps/webapp/build
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/webapp/public
@@ -0,0 +1,5 @@
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
SET lock_timeout = '5s';
-- DropForeignKey
ALTER TABLE "BulkActionItem" DROP CONSTRAINT IF EXISTS "BulkActionItem_groupId_fkey";
@@ -0,0 +1,5 @@
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
SET lock_timeout = '5s';
-- DropForeignKey
ALTER TABLE "BatchTaskRun" DROP CONSTRAINT IF EXISTS "BatchTaskRun_runtimeEnvironmentId_fkey";
@@ -0,0 +1,29 @@
-- Run-ops DB split: drop run-ops tables' cross-database FKs to control-plane tables (they
-- can't be enforced on the run-ops DB, which has no control-plane tables). Mirrors the
-- earlier TaskRun/BatchTaskRun drops; IF EXISTS so it's idempotent across both databases.
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
SET lock_timeout = '5s';
-- Waitpoint
ALTER TABLE "Waitpoint" DROP CONSTRAINT IF EXISTS "Waitpoint_projectId_fkey";
ALTER TABLE "Waitpoint" DROP CONSTRAINT IF EXISTS "Waitpoint_environmentId_fkey";
-- TaskRunWaitpoint
ALTER TABLE "TaskRunWaitpoint" DROP CONSTRAINT IF EXISTS "TaskRunWaitpoint_projectId_fkey";
-- TaskRunCheckpoint
ALTER TABLE "TaskRunCheckpoint" DROP CONSTRAINT IF EXISTS "TaskRunCheckpoint_projectId_fkey";
ALTER TABLE "TaskRunCheckpoint" DROP CONSTRAINT IF EXISTS "TaskRunCheckpoint_runtimeEnvironmentId_fkey";
-- TaskRunAttempt
ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerId_fkey";
ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_backgroundWorkerTaskId_fkey";
ALTER TABLE "TaskRunAttempt" DROP CONSTRAINT IF EXISTS "TaskRunAttempt_runtimeEnvironmentId_fkey";
-- TaskRunTag
ALTER TABLE "TaskRunTag" DROP CONSTRAINT IF EXISTS "TaskRunTag_projectId_fkey";
-- WaitpointTag
ALTER TABLE "WaitpointTag" DROP CONSTRAINT IF EXISTS "WaitpointTag_projectId_fkey";
ALTER TABLE "WaitpointTag" DROP CONSTRAINT IF EXISTS "WaitpointTag_environmentId_fkey";
@@ -0,0 +1,10 @@
-- Run-ops DB split: drop ProjectAlert's cross-DB FKs into the run subgraph (taskRunId -> TaskRun,
-- taskRunAttemptId -> TaskRunAttempt). A ksuid run lives only on the dedicated run-ops DB so the FK
-- can't be enforced; scalar columns are kept and the run is resolved via runStore.findRun. Mirrors
-- ca1a4e18e; IF EXISTS so it's idempotent across both databases.
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
SET lock_timeout = '5s';
ALTER TABLE "ProjectAlert" DROP CONSTRAINT IF EXISTS "ProjectAlert_taskRunId_fkey";
ALTER TABLE "ProjectAlert" DROP CONSTRAINT IF EXISTS "ProjectAlert_taskRunAttemptId_fkey";
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the `_TaskRunToTaskRunTag` table. If the table is not empty, all the data it contains will be lost.
*/
-- Fail fast instead of queueing behind a long txn/VACUUM for the ACCESS EXCLUSIVE lock.
SET lock_timeout = '5s';
-- DropTable
DROP TABLE IF EXISTS "_TaskRunToTaskRunTag" CASCADE;
@@ -369,7 +369,6 @@ model RuntimeEnvironment {
backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[]
taskQueues TaskQueue[]
batchTaskRuns BatchTaskRun[]
environmentVariableValues EnvironmentVariableValue[]
checkpoints Checkpoint[]
workerDeployments WorkerDeployment[]
@@ -939,7 +938,6 @@ model TaskRun {
updatedAt DateTime @updatedAt
attempts TaskRunAttempt[] @relation("attempts")
tags TaskRunTag[]
/// Denormized column that holds the raw tags
runTags String[]
@@ -1004,8 +1002,6 @@ model TaskRun {
CheckpointRestoreEvent CheckpointRestoreEvent[]
executionSnapshots TaskRunExecutionSnapshot[]
alerts ProjectAlert[]
scheduleInstanceId String?
scheduleId String?
@@ -1574,8 +1570,6 @@ model TaskRunTag {
friendlyId String @unique
runs TaskRun[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
@@ -1674,7 +1668,6 @@ model TaskRunAttempt {
checkpoints Checkpoint[]
batchTaskRunItems BatchTaskRunItem[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
alerts ProjectAlert[]
childRuns TaskRun[] @relation("TaskParentRunAttempt")
@@unique([taskRunId, number])
@@ -1872,7 +1865,6 @@ model BatchTaskRun {
friendlyId String @unique
idempotencyKey String?
idempotencyKeyExpiresAt DateTime?
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
status BatchTaskRunStatus @default(PENDING)
runtimeEnvironmentId String
/// This only includes new runs, not idempotent runs.
@@ -2373,10 +2365,10 @@ model ProjectAlert {
type ProjectAlertType
taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
// Run-ops split: these reference run-subgraph rows that live on the dedicated run-ops DB for ksuid
// runs, so the cross-DB FK can't hold. Scalar-only; the run is resolved via runStore.findRun.
taskRunAttemptId String?
taskRun TaskRun? @relation(fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade)
taskRunId String?
workerDeployment WorkerDeployment? @relation(fields: [workerDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@ -2488,7 +2480,6 @@ model BulkActionGroup {
environmentId String?
type BulkActionType
items BulkActionItem[]
/// When the group is created it's pending. After we've processed all the items it's completed. This does not mean the associated runs are completed.
status BulkActionStatus @default(PENDING)
@@ -2556,7 +2547,6 @@ model BulkActionItem {
/// @deprecated not used in new BulkActions
friendlyId String?
group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade)
groupId String
type BulkActionType
@@ -0,0 +1,133 @@
// ProjectAlert.taskRunId/taskRunAttemptId FKs point INTO the run subgraph. A ksuid run lives ONLY
// on the dedicated run-ops DB (prisma17), so `projectAlert.create({ taskRunId: <ksuid> })` on
// control-plane (prisma14) violates the FK and the alert is silently dropped. After the FK drop +
// @relation removal the create succeeds; the read path resolves the run via runStore.findRun.
// Asserts the create succeeds: it fails with an FK violation before the fix and succeeds after.
import { heteroRunOpsPostgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import type { RunOpsPrismaClient } from "@internal/run-ops-database";
import { describe, expect } from "vitest";
// 27-char internal id → ksuid → NEW (lives only on the dedicated run-ops DB).
const KSUID_27 = "k".repeat(27);
async function seedControlPlaneAlertPrereqs(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: "PRODUCTION",
slug: "prod",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${suffix}`,
pkApiKey: `pk_prod_${suffix}`,
shortcode: `short_${suffix}`,
},
});
const channel = await prisma.projectAlertChannel.create({
data: {
friendlyId: `alert_channel_${suffix}`,
type: "EMAIL",
name: "Email",
properties: { type: "EMAIL", email: "alerts@example.com" },
alertTypes: ["TASK_RUN"],
projectId: project.id,
},
});
return { organization, project, environment, channel };
}
describe("ProjectAlert control-plane → run-subgraph FK reconciliation", () => {
heteroRunOpsPostgresTest(
"creating a TASK_RUN alert with a ksuid taskRunId (run only on the run-ops DB) succeeds on control-plane",
async ({ prisma14, prisma17 }) => {
const suffix = "alert-ksuid";
const { project, environment, channel } = await seedControlPlaneAlertPrereqs(
prisma14,
suffix
);
// The run exists ONLY on the dedicated run-ops DB (prisma17), never on control-plane.
await (prisma17 as RunOpsPrismaClient).taskRun.create({
data: {
id: KSUID_27,
friendlyId: `run_${suffix}`,
engine: "V2",
status: "COMPLETED_WITH_ERRORS",
taskIdentifier: "my-task",
payload: "{}",
payloadType: "application/json",
traceId: `trace_${suffix}`,
spanId: `span_${suffix}`,
queue: "task/my-task",
runtimeEnvironmentId: environment.id,
projectId: project.id,
organizationId: project.organizationId,
environmentType: "PRODUCTION",
},
});
// Control-plane has no TaskRun row for KSUID_27. With the FK present this throws P2003;
// after the FK is dropped + the @relation removed it succeeds.
const alert = await prisma14.projectAlert.create({
data: {
friendlyId: `alert_${suffix}`,
channelId: channel.id,
projectId: project.id,
environmentId: environment.id,
status: "PENDING",
type: "TASK_RUN",
taskRunId: KSUID_27,
},
});
expect(alert.taskRunId).toBe(KSUID_27);
// The scalar round-trips and can be re-read off the control-plane row (the read path resolves
// the actual run via runStore.findRun against the run-ops DB).
const reread = await prisma14.projectAlert.findUniqueOrThrow({ where: { id: alert.id } });
expect(reread.taskRunId).toBe(KSUID_27);
},
120_000
);
heteroRunOpsPostgresTest(
"creating a TASK_RUN_ATTEMPT alert with a ksuid taskRunAttemptId (attempt only on the run-ops DB) succeeds on control-plane",
async ({ prisma14 }) => {
const suffix = "alert-ksuid-attempt";
const { project, environment, channel } = await seedControlPlaneAlertPrereqs(
prisma14,
suffix
);
// A ksuid attempt id with no matching control-plane TaskRunAttempt row. With the FK present
// this throws P2003; after the FK is dropped it succeeds.
const attemptId = "a".repeat(27);
const alert = await prisma14.projectAlert.create({
data: {
friendlyId: `alert_${suffix}`,
channelId: channel.id,
projectId: project.id,
environmentId: environment.id,
status: "PENDING",
type: "TASK_RUN_ATTEMPT",
taskRunAttemptId: attemptId,
},
});
expect(alert.taskRunAttemptId).toBe(attemptId);
},
120_000
);
});