Skip to content

Commit e5689cc

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
fix(webapp,run-engine): stop delayed runs firing after a project is deleted
Deleting a project didn't prevent delayed runs from being queued later. When the delay elapsed the run was enqueued, which re-added the queue, and it executed and alerted the project's channels long after the project was gone. Cron schedules were already gated on deletion; the delayed-run path had no equivalent guard. Mono-RevId: 04d8246437c4cc661640b2a457455518dded7638
1 parent 07ff270 commit e5689cc

17 files changed

Lines changed: 908 additions & 5 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Deleting a project now stops its pending runs. Runs that were waiting on a `delay` or sitting in the queue are cancelled instead of executing later, and a deleted project no longer sends task failure alerts.

‎apps/webapp/app/services/deleteProject.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class DeleteProjectService {
5858
},
5959
});
6060

61-
// project.deletedAt (which gates env resolution) changed; drop every cached env of this project.
61+
/** project.deletedAt, which the engine gates enqueue and dequeue on, changed; drop every cached env of this project. */
6262
for (const environment of project.environments) {
6363
controlPlaneResolver.invalidateEnvironment(environment.id);
6464
}

‎apps/webapp/app/v3/runOpsMigration/controlPlaneCache.server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,20 @@ export type ResolvedEnv = {
4040
// ignore these additive fields.
4141
maximumConcurrencyLimit: number;
4242
concurrencyLimitBurstFactor: Prisma.Decimal;
43+
/**
44+
* Soft-delete tombstones. The engine gates enqueue and dequeue on these, and alerts skip a
45+
* deleted project, so a deleted project's pending work neither runs nor pages anyone.
46+
*/
47+
projectDeletedAt: Date | null;
48+
organizationDeletedAt: Date | null;
4349
};
4450

51+
/**
52+
* Just the soft-delete tombstones. Structurally identical to the run-engine
53+
* `EnvDeletionState`; declared here so the app resolver stays engine-agnostic.
54+
*/
55+
export type EnvDeletionState = Pick<ResolvedEnv, "projectDeletedAt" | "organizationDeletedAt">;
56+
4557
/**
4658
* The BackgroundWorkerTask columns the dequeue resolve path reads. Mirrors run-engine's
4759
* `ResolvedWorkerTask` exactly. The unread heavy JSON columns (`payloadSchema`, `config`,

‎apps/webapp/app/v3/runOpsMigration/controlPlaneResolver.server.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolvedWorkerDeploymentSelect,
1515
resolvedWorkerTaskSelect,
1616
type ResolvedAuthenticatedEnv,
17+
type EnvDeletionState,
1718
type ResolvedEnv,
1819
type ResolvedWorkerVersion,
1920
type ResolvedRunLockedWorker,
@@ -140,7 +141,13 @@ export class ControlPlaneResolver {
140141
archivedAt: true,
141142
maximumConcurrencyLimit: true,
142143
concurrencyLimitBurstFactor: true,
143-
project: { select: { organizationId: true } },
144+
project: {
145+
select: {
146+
organizationId: true,
147+
deletedAt: true,
148+
organization: { select: { deletedAt: true } },
149+
},
150+
},
144151
parentEnvironment: { select: { type: true } },
145152
},
146153
});
@@ -158,6 +165,36 @@ export class ControlPlaneResolver {
158165
parentEnvironmentType: env.parentEnvironment?.type ?? null,
159166
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
160167
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor,
168+
projectDeletedAt: env.project.deletedAt,
169+
organizationDeletedAt: env.project.organization.deletedAt,
170+
};
171+
}
172+
173+
/**
174+
* The soft-delete tombstones, always read from the control-plane primary and never cached.
175+
*
176+
* `resolveEnv` is cache-fronted per process when split is on, and `invalidateEnvironment` /
177+
* `invalidateOrganization` only clear the process that handled the delete, so every other
178+
* process can serve pre-deletion state until its entry expires. A caller whose decision must
179+
* not be made on that stale value uses this instead.
180+
*/
181+
async resolveEnvDeletionState(environmentId: string): Promise<EnvDeletionState | null> {
182+
const env = await this.controlPlanePrimary.runtimeEnvironment.findFirst({
183+
where: { id: environmentId },
184+
select: {
185+
project: {
186+
select: { deletedAt: true, organization: { select: { deletedAt: true } } },
187+
},
188+
},
189+
});
190+
191+
if (!env) {
192+
return null;
193+
}
194+
195+
return {
196+
projectDeletedAt: env.project.deletedAt,
197+
organizationDeletedAt: env.project.organization.deletedAt,
161198
};
162199
}
163200

‎apps/webapp/app/v3/runOpsMigration/runEngineControlPlaneResolver.server.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
ControlPlaneResolver as EngineControlPlaneResolver,
3+
EnvDeletionState,
34
ResolvedAuthenticatedEnv,
45
ResolvedEngineEnv,
56
ResolvedWorkerVersion,
@@ -40,11 +41,17 @@ export class RunEngineControlPlaneResolver implements EngineControlPlaneResolver
4041
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor,
4142
projectId: env.projectId,
4243
organizationId: env.organizationId,
44+
projectDeletedAt: env.projectDeletedAt,
45+
organizationDeletedAt: env.organizationDeletedAt,
4346
project: { id: env.projectId },
4447
organization: { id: env.organizationId },
4548
};
4649
}
4750

51+
async resolveEnvDeletionState(environmentId: string): Promise<EnvDeletionState | null> {
52+
return this.#resolver.resolveEnvDeletionState(environmentId);
53+
}
54+
4855
async resolveWorkerVersion(args: {
4956
environmentId: string;
5057
type: RuntimeEnvironmentType;

‎apps/webapp/app/v3/services/alerts/performTaskRunAlerts.server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ export class PerformTaskRunAlertsService extends BaseService {
6767
},
6868
});
6969

70+
if (alertChannels.length === 0) {
71+
return;
72+
}
73+
74+
const deletionState = await this.#controlPlaneResolver.resolveEnvDeletionState(
75+
run.runtimeEnvironmentId
76+
);
77+
78+
if (!deletionState || deletionState.projectDeletedAt || deletionState.organizationDeletedAt) {
79+
return;
80+
}
81+
7082
for (const alertChannel of alertChannels) {
7183
await this.#createAndSendAlert(alertChannel, run);
7284
}

‎apps/webapp/test/performTaskRunAlertsStoreRouting.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,71 @@ describe("PerformTaskRunAlertsService passthrough (single-DB)", () => {
245245
}
246246
);
247247
});
248+
249+
async function seedAlertableRun(prisma: PrismaClient, suffix: string) {
250+
const id = generateRunOpsId();
251+
const { project, organization, runtimeEnvironment } = await seedProject(prisma, suffix);
252+
253+
await seedRun(
254+
prisma,
255+
{ id, friendlyId: `run_${id}` },
256+
{
257+
runtimeEnvironmentId: runtimeEnvironment.id,
258+
projectId: project.id,
259+
organizationId: organization.id,
260+
}
261+
);
262+
await prisma.projectAlertChannel.create({
263+
data: {
264+
friendlyId: `alert_${id}`,
265+
name: "test-channel",
266+
projectId: project.id,
267+
alertTypes: ["TASK_RUN"],
268+
environmentTypes: ["PRODUCTION"],
269+
type: "EMAIL",
270+
properties: { type: "EMAIL", email: "test@example.com" },
271+
enabled: true,
272+
},
273+
});
274+
275+
return { id, project, organization };
276+
}
277+
278+
function buildAlertsService(prisma: PrismaClient) {
279+
return new PerformTaskRunAlertsService({
280+
prisma,
281+
runStore: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }),
282+
controlPlaneResolver: buildControlPlaneResolver(prisma),
283+
});
284+
}
285+
286+
describe("PerformTaskRunAlertsService deleted project/organization", () => {
287+
postgresTest("sends no alert once the project is deleted", async ({ prisma }) => {
288+
const { id, project } = await seedAlertableRun(prisma, "del-project");
289+
290+
await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } });
291+
292+
await buildAlertsService(prisma)
293+
.call(id)
294+
.catch(() => {});
295+
296+
const delivered = await prisma.projectAlert.findMany({ where: { projectId: project.id } });
297+
expect(delivered.length).toBe(0);
298+
});
299+
300+
postgresTest("sends no alert once the organization is deleted", async ({ prisma }) => {
301+
const { id, project, organization } = await seedAlertableRun(prisma, "del-org");
302+
303+
await prisma.organization.update({
304+
where: { id: organization.id },
305+
data: { deletedAt: new Date() },
306+
});
307+
308+
await buildAlertsService(prisma)
309+
.call(id)
310+
.catch(() => {});
311+
312+
const delivered = await prisma.projectAlert.findMany({ where: { projectId: project.id } });
313+
expect(delivered.length).toBe(0);
314+
});
315+
});

‎apps/webapp/test/v3/runOpsMigration/runEngineControlPlaneResolver.server.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,56 @@ describe("RunEngineControlPlaneResolver adapter", () => {
154154
expect(env!.maximumConcurrencyLimit).toBe(9);
155155
expect(env!.concurrencyLimitBurstFactor.toNumber()).toBe(2);
156156
expect(env!.archivedAt).toBeNull();
157+
expect(env!.projectDeletedAt).toBeNull();
158+
expect(env!.organizationDeletedAt).toBeNull();
157159

158160
expect(await adapter.resolveEnv("env_missing")).toBeNull();
159161
}
160162
);
161163

164+
heteroPostgresTest(
165+
"resolveEnv carries the project and organization soft-delete tombstones the engine guards on",
166+
async ({ prisma14 }) => {
167+
const { organization, project, environment } = await seedEnv(prisma14, "PRODUCTION");
168+
const deletedAt = new Date();
169+
170+
await prisma14.project.update({ where: { id: project.id }, data: { deletedAt } });
171+
await prisma14.organization.update({ where: { id: organization.id }, data: { deletedAt } });
172+
173+
const env = await new RunEngineControlPlaneResolver(buildAppResolver(prisma14)).resolveEnv(
174+
environment.id
175+
);
176+
177+
expect(env!.projectDeletedAt).toEqual(deletedAt);
178+
expect(env!.organizationDeletedAt).toEqual(deletedAt);
179+
}
180+
);
181+
182+
/**
183+
* The delete is applied WITHOUT calling invalidateEnvironment, which models it happening in a
184+
* different process: that is the only kind this process's cache never hears about, and the
185+
* reason the guards cannot make their decision on `resolveEnv`.
186+
*/
187+
heteroPostgresTest(
188+
"resolveEnvDeletionState sees a deletion the warm per-process env cache is still hiding",
189+
async ({ prisma14 }) => {
190+
const { project, environment } = await seedEnv(prisma14, "PRODUCTION");
191+
const appResolver = buildAppResolver(prisma14, { splitEnabled: true });
192+
const adapter = new RunEngineControlPlaneResolver(appResolver);
193+
194+
expect((await adapter.resolveEnv(environment.id))!.projectDeletedAt).toBeNull();
195+
196+
const deletedAt = new Date();
197+
await prisma14.project.update({ where: { id: project.id }, data: { deletedAt } });
198+
199+
expect((await adapter.resolveEnv(environment.id))!.projectDeletedAt).toBeNull();
200+
201+
const deletionState = await adapter.resolveEnvDeletionState(environment.id);
202+
expect(deletionState!.projectDeletedAt).toEqual(deletedAt);
203+
expect(await appResolver.resolveEnvDeletionState("env_missing")).toBeNull();
204+
}
205+
);
206+
162207
heteroPostgresTest(
163208
"resolveWorkerVersion (deployed, no workerId) resolves the promoted MANAGED deployment",
164209
async ({ prisma14 }) => {

‎internal-packages/run-engine/src/engine/controlPlaneResolver.ts‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environ
2727
* so a `ResolvedEngineEnv` is a structural supertype of it and can be passed directly
2828
* to `enqueueSystem.enqueueRun({ env })`. `concurrencyLimitBurstFactor` stays a
2929
* `Prisma.Decimal` (do NOT coerce); `maximumConcurrencyLimit` is non-null per schema.
30+
*
31+
* `projectDeletedAt`/`organizationDeletedAt` are the soft-delete tombstones the engine gates
32+
* enqueue and dequeue on, so a deleted project's work stops instead of running weeks later.
33+
* Every implementation of this interface MUST populate them; a resolver that leaves them null
34+
* silently disables both guards.
3035
*/
3136
export type ResolvedEngineEnv = {
3237
id: string;
@@ -36,10 +41,48 @@ export type ResolvedEngineEnv = {
3641
concurrencyLimitBurstFactor: Prisma.Decimal;
3742
projectId: string;
3843
organizationId: string;
44+
projectDeletedAt: Date | null;
45+
organizationDeletedAt: Date | null;
3946
project: { id: string };
4047
organization: { id: string };
4148
};
4249

50+
/**
51+
* Just the soft-delete tombstones, read from the authoritative source rather than from
52+
* whatever `resolveEnv` has cached.
53+
*/
54+
export type EnvDeletionState = Pick<
55+
ResolvedEngineEnv,
56+
"projectDeletedAt" | "organizationDeletedAt"
57+
>;
58+
59+
/**
60+
* Recorded when the environment row itself is absent, so there is no tombstone to read. Only
61+
* ever reached from an authoritative read: a cached absence is not trustworthy enough to end
62+
* a run on.
63+
*/
64+
export const MISSING_ENVIRONMENT_REASON = "The environment was deleted";
65+
66+
/**
67+
* The reason a run on this environment must not execute, or `null` when it may. Doubles as the
68+
* enqueue/dequeue guard and as the cancellation reason recorded on the run, so the two call
69+
* sites cannot drift apart.
70+
*/
71+
export function deletedEnvironmentReason(env: {
72+
projectDeletedAt: Date | null;
73+
organizationDeletedAt: Date | null;
74+
}): string | null {
75+
if (env.organizationDeletedAt) {
76+
return "The organization was deleted";
77+
}
78+
79+
if (env.projectDeletedAt) {
80+
return "The project was deleted";
81+
}
82+
83+
return null;
84+
}
85+
4386
/**
4487
* The richer control-plane env primitive: the slim, structural
4588
* `AuthenticatedEnvironment` (slug/branchName/project/organization/…) PLUS the
@@ -127,6 +170,14 @@ export type WorkerVersionDispatchFilter = {
127170

128171
export interface ControlPlaneResolver {
129172
resolveEnv(environmentId: string): Promise<ResolvedEngineEnv | null>;
173+
/**
174+
* The deletion tombstones read straight from the authoritative control-plane source, never
175+
* from a cache. An implementation may serve `resolveEnv` from a per-process cache whose
176+
* invalidation does not reach other processes, so a decision that must not act on
177+
* pre-deletion state for even a moment reads it here instead. Returns `null` when the
178+
* environment does not exist.
179+
*/
180+
resolveEnvDeletionState(environmentId: string): Promise<EnvDeletionState | null>;
130181
resolveAuthenticatedEnv(environmentId: string): Promise<ResolvedAuthenticatedEnv | null>;
131182
resolveWorkerVersion(
132183
args: {
@@ -174,8 +225,8 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver {
174225
maximumConcurrencyLimit: true,
175226
concurrencyLimitBurstFactor: true,
176227
projectId: true,
177-
project: { select: { id: true, organizationId: true } },
178-
organization: { select: { id: true } },
228+
project: { select: { id: true, organizationId: true, deletedAt: true } },
229+
organization: { select: { id: true, deletedAt: true } },
179230
},
180231
});
181232

@@ -191,11 +242,38 @@ export class PassthroughControlPlaneResolver implements ControlPlaneResolver {
191242
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor,
192243
projectId: env.projectId,
193244
organizationId: env.project.organizationId,
245+
projectDeletedAt: env.project.deletedAt,
246+
organizationDeletedAt: env.organization.deletedAt,
194247
project: { id: env.project.id },
195248
organization: { id: env.organization.id },
196249
};
197250
}
198251

252+
/**
253+
* Passthrough holds no cache, so `resolveEnv` is already authoritative and this exists to
254+
* satisfy the seam. It reads the org tombstone through `project.organization` (the same path
255+
* the webapp resolver uses) rather than the environment's own organization relation.
256+
*/
257+
async resolveEnvDeletionState(environmentId: string): Promise<EnvDeletionState | null> {
258+
const env = await this.#prisma.runtimeEnvironment.findFirst({
259+
where: { id: environmentId },
260+
select: {
261+
project: {
262+
select: { deletedAt: true, organization: { select: { deletedAt: true } } },
263+
},
264+
},
265+
});
266+
267+
if (!env) {
268+
return null;
269+
}
270+
271+
return {
272+
projectDeletedAt: env.project.deletedAt,
273+
organizationDeletedAt: env.project.organization.deletedAt,
274+
};
275+
}
276+
199277
async resolveAuthenticatedEnv(environmentId: string): Promise<ResolvedAuthenticatedEnv | null> {
200278
const env = await this.#prisma.runtimeEnvironment.findFirst({
201279
where: { id: environmentId },

0 commit comments

Comments
 (0)