Skip to content

Commit 8b0385c

Browse files
authored
feat(run-engine): trigger tasks pinned to an external deployment id (#4664)
The SDK discovers an external deployment id at runtime (explicit TRIGGER_EXTERNAL_DEPLOYMENT_ID always; platform commit-SHA variables and generic fallbacks when TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1) and sends it alongside lockToVersion; the server resolves precedence (version > external id > current). An id held by a deployed deployment pins the run to that worker; an in-flight or unknown id parks the run in PENDING_VERSION with the id in TaskRun.annotations, wakes it pinned when a deployment carrying the id finalizes (ClickHouse candidates, Postgres authoritative), and expires it after a deadline that re-checks Postgres before acting. Parking outranks delaying and preserves delayUntil. The id is projected to ClickHouse task_runs_v2.external_deployment_id during replication. Redis cache for id-to-worker resolution, guarded version-aware writes. Ids are not unique. Several deployments can hold one id - a --force rebuild is the ordinary way to get there - so resolution always picks the highest version among the candidates, never the newest by timestamp. The rule is applied identically on both paths that can bind a run to a worker: resolveExternalDeployment at trigger time, and PendingVersionSystem when a landing deployment wakes a parked run. Version comparison is numeric on the counter half, so 20260807.10 outranks 20260807.9. A run whose id never lands expires at the deadline with EXTERNAL_DEPLOYMENT_NOT_FOUND and an error naming the id it waited for, which is what a failed build or a typo looks like from the caller. Default deadline is one hour (EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS). Debounce registration happens in both the parked and the delayed branch through one helper, so a debounced run that parks still binds its debounce key; without it every later trigger for the same key created another parked run, and all of them executed when the deployment landed. The two DELAYED-only status checks in DebounceSystem also accept PENDING_VERSION, without which the lock-contention fallback would rethrow a 5xx the SDK retries and amplifies, and the fast path would push every trigger on a parked key through the redlock. Resolution is skipped in development. A dev environment cannot hold a WorkerDeployment - trigger dev registers a BackgroundWorker with nothing behind it, and deploy --env refuses dev - so an external deployment id there could only ever park, and the parked run then expired against the dev TTL while a connected dev worker sat idle. The id is still annotated so the dashboard shows what the app sent (TRI-13000).
1 parent 6bfce63 commit 8b0385c

37 files changed

Lines changed: 3005 additions & 72 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Pin runs to the deployment your calling code came from, so an old release never triggers tasks from a new one: set `TRIGGER_EXTERNAL_DEPLOYMENT_ID` to the id you deployed with, or `TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1` to detect the commit automatically on Vercel and most CI systems. Runs triggered before that deployment finishes building wait for it, then start pinned.

apps/webapp/app/env.server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,31 @@ const EnvironmentSchema = z
488488
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
489489
TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400),
490490

491+
EXTERNAL_DEPLOYMENT_CACHE_REDIS_HOST: z
492+
.string()
493+
.optional()
494+
.transform((v) => v ?? process.env.REDIS_HOST),
495+
EXTERNAL_DEPLOYMENT_CACHE_REDIS_PORT: z.coerce
496+
.number()
497+
.optional()
498+
.transform(
499+
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
500+
),
501+
EXTERNAL_DEPLOYMENT_CACHE_REDIS_USERNAME: z
502+
.string()
503+
.optional()
504+
.transform((v) => v ?? process.env.REDIS_USERNAME),
505+
EXTERNAL_DEPLOYMENT_CACHE_REDIS_PASSWORD: z
506+
.string()
507+
.optional()
508+
.transform((v) => v ?? process.env.REDIS_PASSWORD),
509+
EXTERNAL_DEPLOYMENT_CACHE_REDIS_TLS_DISABLED: z
510+
.string()
511+
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
512+
EXTERNAL_DEPLOYMENT_CACHE_TTL_SECONDS: z.coerce.number().default(2592000),
513+
EXTERNAL_DEPLOYMENT_CACHE_MISSING_TTL_SECONDS: z.coerce.number().default(20),
514+
EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS: z.coerce.number().default(3600000),
515+
491516
// Runs-list empty-state check: how far back the ClickHouse "does this env have any run"
492517
// probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever").
493518
RUN_LIST_HAS_RUNS_LOOKBACK_DAYS: z.coerce.number().default(30),

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ import {
7474
import { mollifyTrigger } from "~/v3/mollifier/mollifierMollify.server";
7575
import { QueueSizeLimitExceededError, ServiceValidationError } from "~/v3/services/common.server";
7676
import { runStore } from "~/v3/runStore.server";
77+
import type { ExternalDeploymentCache } from "~/services/externalDeploymentCache.server";
78+
import { externalDeploymentCacheInstance } from "~/services/externalDeploymentCacheInstance.server";
79+
import { resolveExternalDeployment } from "~/v3/services/resolveExternalDeployment.server";
7780

7881
class NoopTriggerRacepointSystem implements TriggerRacepointSystem {
7982
async waitForRacepoint(options: { racepoint: TriggerRacepoints; id: string }): Promise<void> {
@@ -101,6 +104,7 @@ export class RunEngineTriggerTaskService {
101104
private readonly evaluateGate: MollifierEvaluateGate;
102105
private readonly getMollifierBuffer: MollifierGetBuffer;
103106
private readonly isMollifierGloballyEnabled: () => boolean;
107+
private readonly externalDeploymentCache: ExternalDeploymentCache;
104108

105109
constructor(opts: {
106110
prisma: PrismaClientOrTransaction;
@@ -117,6 +121,7 @@ export class RunEngineTriggerTaskService {
117121
evaluateGate?: MollifierEvaluateGate;
118122
getMollifierBuffer?: MollifierGetBuffer;
119123
isMollifierGloballyEnabled?: () => boolean;
124+
externalDeploymentCache?: ExternalDeploymentCache;
120125
}) {
121126
this.prisma = opts.prisma;
122127
this.engine = opts.engine;
@@ -134,6 +139,7 @@ export class RunEngineTriggerTaskService {
134139
this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer;
135140
this.isMollifierGloballyEnabled =
136141
opts.isMollifierGloballyEnabled ?? (() => env.TRIGGER_MOLLIFIER_ENABLED === "1");
142+
this.externalDeploymentCache = opts.externalDeploymentCache ?? externalDeploymentCacheInstance;
137143
}
138144

139145
/**
@@ -394,7 +400,7 @@ export class RunEngineTriggerTaskService {
394400
});
395401
}
396402

397-
const lockedToBackgroundWorker = body.options?.lockToVersion
403+
const explicitlyLockedToBackgroundWorker = body.options?.lockToVersion
398404
? await this.prisma.backgroundWorker.findFirst({
399405
where: {
400406
projectId: environment.projectId,
@@ -410,6 +416,34 @@ export class RunEngineTriggerTaskService {
410416
})
411417
: undefined;
412418

419+
const externalDeploymentId = body.options?.lockToVersion
420+
? undefined
421+
: body.options?.externalDeploymentId;
422+
423+
const externalDeploymentResolution =
424+
externalDeploymentId && environment.type !== "DEVELOPMENT"
425+
? await resolveExternalDeployment({
426+
prisma: this.prisma,
427+
environmentId: environment.id,
428+
externalDeploymentId,
429+
cache: this.externalDeploymentCache,
430+
})
431+
: undefined;
432+
433+
const lockedToBackgroundWorker =
434+
explicitlyLockedToBackgroundWorker ??
435+
(externalDeploymentResolution?.outcome === "deployed"
436+
? {
437+
id: externalDeploymentResolution.worker.workerId,
438+
version: externalDeploymentResolution.worker.version,
439+
sdkVersion: externalDeploymentResolution.worker.sdkVersion,
440+
cliVersion: externalDeploymentResolution.worker.cliVersion,
441+
}
442+
: undefined);
443+
444+
const parkedOnExternalDeploymentId =
445+
externalDeploymentResolution?.outcome === "park" ? externalDeploymentId : undefined;
446+
413447
const { queueName, lockedQueueId, taskTtl, taskKind } =
414448
await this.queueConcern.resolveQueueProperties(
415449
triggerRequest,
@@ -503,6 +537,7 @@ export class RunEngineTriggerTaskService {
503537
rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource,
504538
rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined,
505539
taskKind: taskKind ?? "STANDARD",
540+
externalDeploymentId,
506541
};
507542

508543
// Route runs in a scheduled lineage (the scheduled run itself and every
@@ -638,6 +673,7 @@ export class RunEngineTriggerTaskService {
638673
depth,
639674
parentRun: parentRun ?? undefined,
640675
annotations,
676+
parkedOnExternalDeploymentId,
641677
planType,
642678
taskId,
643679
payloadPacket,
@@ -717,6 +753,7 @@ export class RunEngineTriggerTaskService {
717753
depth,
718754
parentRun: parentRun ?? undefined,
719755
annotations,
756+
parkedOnExternalDeploymentId,
720757
planType,
721758
taskId,
722759
payloadPacket,
@@ -892,7 +929,9 @@ export class RunEngineTriggerTaskService {
892929
triggerAction: string;
893930
rootTriggerSource: string;
894931
rootScheduleId?: string | undefined;
932+
externalDeploymentId?: string | undefined;
895933
};
934+
parkedOnExternalDeploymentId?: string;
896935
planType?: string;
897936
taskId: string;
898937
payloadPacket: { data?: string; dataType: string };
@@ -974,6 +1013,7 @@ export class RunEngineTriggerTaskService {
9741013
streamBasinName: args.environment.organization.streamBasinName,
9751014
debounce: removeNullBytesFromKey(args.body.options?.debounce),
9761015
annotations: args.annotations,
1016+
parkedOnExternalDeploymentId: args.parkedOnExternalDeploymentId,
9771017
};
9781018
}
9791019

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { Callback, Redis, Result } from "ioredis";
2+
import { logger } from "./logger.server";
3+
4+
export type ExternalDeploymentCacheEntry = {
5+
workerId: string;
6+
version: string;
7+
sdkVersion: string;
8+
cliVersion: string;
9+
};
10+
11+
export type ExternalDeploymentCacheResult =
12+
| { outcome: "deployed"; entry: ExternalDeploymentCacheEntry }
13+
| { outcome: "missing" };
14+
15+
export interface ExternalDeploymentCache {
16+
get(environmentId: string, externalId: string): Promise<ExternalDeploymentCacheResult | null>;
17+
setIfNewer(
18+
environmentId: string,
19+
externalId: string,
20+
entry: ExternalDeploymentCacheEntry
21+
): Promise<void>;
22+
setMissing(environmentId: string, externalId: string): Promise<void>;
23+
}
24+
25+
const KEY_PREFIX = "skewid:";
26+
27+
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
28+
29+
const DEFAULT_MISSING_TTL_SECONDS = 20;
30+
31+
const MISSING_ENTRY = JSON.stringify({ m: 1 });
32+
33+
function buildKey(environmentId: string, externalId: string): string {
34+
return `${KEY_PREFIX}${environmentId}:${externalId}`;
35+
}
36+
37+
type CachedEntry = {
38+
w: string;
39+
v: string;
40+
s: string;
41+
c: string;
42+
};
43+
44+
function encode(entry: ExternalDeploymentCacheEntry): string {
45+
return JSON.stringify({
46+
w: entry.workerId,
47+
v: entry.version,
48+
s: entry.sdkVersion,
49+
c: entry.cliVersion,
50+
} satisfies CachedEntry);
51+
}
52+
53+
function decode(raw: string): ExternalDeploymentCacheResult | null {
54+
const parsed: unknown = JSON.parse(raw);
55+
56+
if (typeof parsed !== "object" || parsed === null) {
57+
return null;
58+
}
59+
60+
const { w, v, s, c, m } = parsed as Partial<CachedEntry> & { m?: unknown };
61+
62+
if (m === 1) {
63+
return { outcome: "missing" };
64+
}
65+
66+
if (typeof w !== "string" || typeof v !== "string") {
67+
return null;
68+
}
69+
70+
return {
71+
outcome: "deployed",
72+
entry: {
73+
workerId: w,
74+
version: v,
75+
sdkVersion: typeof s === "string" ? s : "",
76+
cliVersion: typeof c === "string" ? c : "",
77+
},
78+
};
79+
}
80+
81+
const SET_IF_NEWER_LUA = `
82+
local existing = redis.call("GET", KEYS[1])
83+
84+
if existing then
85+
local ok, decoded = pcall(cjson.decode, existing)
86+
if ok and type(decoded) == "table" and type(decoded.v) == "string" then
87+
local existingDate, existingCounter = string.match(decoded.v, "^([^.]*)%.?(.*)$")
88+
local incomingDate, incomingCounter = string.match(ARGV[2], "^([^.]*)%.?(.*)$")
89+
90+
if existingDate > incomingDate then
91+
return 0
92+
end
93+
94+
if existingDate == incomingDate then
95+
if (tonumber(existingCounter) or 0) >= (tonumber(incomingCounter) or 0) then
96+
return 0
97+
end
98+
end
99+
end
100+
end
101+
102+
redis.call("SET", KEYS[1], ARGV[1], "EX", tonumber(ARGV[3]))
103+
return 1
104+
`;
105+
106+
declare module "ioredis" {
107+
interface RedisCommander<Context> {
108+
skewIdSetIfNewer(
109+
key: string,
110+
entry: string,
111+
version: string,
112+
ttlSeconds: string,
113+
callback?: Callback<number>
114+
): Result<number, Context>;
115+
}
116+
}
117+
118+
export type RedisExternalDeploymentCacheOptions = {
119+
redis: Redis;
120+
ttlSeconds?: number;
121+
missingTtlSeconds?: number;
122+
};
123+
124+
export class RedisExternalDeploymentCache implements ExternalDeploymentCache {
125+
private readonly redis: Redis;
126+
private readonly ttlSeconds: number;
127+
private readonly missingTtlSeconds: number;
128+
129+
constructor(options: RedisExternalDeploymentCacheOptions) {
130+
this.redis = options.redis;
131+
this.ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
132+
this.missingTtlSeconds = options.missingTtlSeconds ?? DEFAULT_MISSING_TTL_SECONDS;
133+
134+
this.redis.defineCommand("skewIdSetIfNewer", { numberOfKeys: 1, lua: SET_IF_NEWER_LUA });
135+
}
136+
137+
async get(
138+
environmentId: string,
139+
externalId: string
140+
): Promise<ExternalDeploymentCacheResult | null> {
141+
try {
142+
const raw = await this.redis.get(buildKey(environmentId, externalId));
143+
if (!raw) return null;
144+
return decode(raw);
145+
} catch (error) {
146+
logger.error("Failed to read external deployment resolution from cache", {
147+
environmentId,
148+
externalId,
149+
error,
150+
});
151+
return null;
152+
}
153+
}
154+
155+
async setIfNewer(
156+
environmentId: string,
157+
externalId: string,
158+
entry: ExternalDeploymentCacheEntry
159+
): Promise<void> {
160+
try {
161+
await this.redis.skewIdSetIfNewer(
162+
buildKey(environmentId, externalId),
163+
encode(entry),
164+
entry.version,
165+
String(this.ttlSeconds)
166+
);
167+
} catch (error) {
168+
logger.error("Failed to write external deployment resolution to cache", {
169+
environmentId,
170+
externalId,
171+
version: entry.version,
172+
error,
173+
});
174+
175+
try {
176+
await this.redis.del(buildKey(environmentId, externalId));
177+
} catch (deleteError) {
178+
logger.error("Failed to evict stale external deployment resolution after write failure", {
179+
environmentId,
180+
externalId,
181+
error: deleteError,
182+
});
183+
}
184+
}
185+
}
186+
187+
async setMissing(environmentId: string, externalId: string): Promise<void> {
188+
try {
189+
await this.redis.set(
190+
buildKey(environmentId, externalId),
191+
MISSING_ENTRY,
192+
"EX",
193+
this.missingTtlSeconds,
194+
"NX"
195+
);
196+
} catch (error) {
197+
logger.error("Failed to write missing external deployment marker to cache", {
198+
environmentId,
199+
externalId,
200+
error,
201+
});
202+
}
203+
}
204+
}
205+
206+
export class NoopExternalDeploymentCache implements ExternalDeploymentCache {
207+
async get(): Promise<ExternalDeploymentCacheResult | null> {
208+
return null;
209+
}
210+
211+
async setIfNewer(): Promise<void> {}
212+
213+
async setMissing(): Promise<void> {}
214+
}

0 commit comments

Comments
 (0)