Skip to content

Commit 8f7617a

Browse files
committed
feat(run-engine): trigger tasks pinned to an external deployment id
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 18ec657 commit 8f7617a

33 files changed

Lines changed: 2646 additions & 42 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Runs can now be pinned to the deployment that your calling code came from, so a request served by an old release never triggers tasks from a new one.
7+
8+
Deploy with an id — a commit SHA, a CI run id, a release tag — and give the running app the same value:
9+
10+
```bash
11+
trigger.dev deploy --external-id "$COMMIT_SHA"
12+
TRIGGER_EXTERNAL_DEPLOYMENT_ID="$COMMIT_SHA"
13+
```
14+
15+
Every trigger from that app then runs on that deployment's tasks. On Vercel, Railway, Render, Cloudflare Pages, Koyeb and most CI systems you can skip the second line and set `TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1` instead — the SDK finds the commit itself. You can also set it per call:
16+
17+
```ts
18+
await myTask.trigger({ foo: "bar" }, { externalDeploymentId: commitSha });
19+
```
20+
21+
**While the deployment is still building**, the run waits instead of running on the wrong version. It starts as soon as a deployment carrying that id finishes, pinned to it. This is the normal case when your app deploys before its tasks finish building.
22+
23+
**If the deployment never arrives** — the build failed, or nothing was ever deployed under that id — the run waits for an hour and then expires, and the error names the id it was waiting for. It is never quietly sent to a different release. Trigger a run with an id you have not deployed yet and this is what you get, so an id typo shows up as an expired run rather than a run on the wrong code.
24+
25+
**If several deployments share one id**, the run goes to the highest version among them. This is what you get after `trigger.dev deploy --external-id "$COMMIT_SHA" --force`, which builds a second deployment for an id that already has one: both are kept and new runs use the newer.
26+
27+
Triggering repeatedly with the same id is stable — every run lands on the same deployment until a newer one claims that id.
28+
29+
An explicit `version` (or `TRIGGER_VERSION`) still takes precedence over the id, and triggers that send no id behave exactly as before.

apps/webapp/app/env.server.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,30 @@ 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_PARK_DEADLINE_MS: z.coerce.number().default(3600000),
514+
491515
// Runs-list empty-state check: how far back the ClickHouse "does this env have any run"
492516
// probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever").
493517
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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 interface ExternalDeploymentCache {
12+
get(environmentId: string, externalId: string): Promise<ExternalDeploymentCacheEntry | null>;
13+
setIfNewer(
14+
environmentId: string,
15+
externalId: string,
16+
entry: ExternalDeploymentCacheEntry
17+
): Promise<void>;
18+
}
19+
20+
const KEY_PREFIX = "skewid:";
21+
22+
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
23+
24+
function buildKey(environmentId: string, externalId: string): string {
25+
return `${KEY_PREFIX}${environmentId}:${externalId}`;
26+
}
27+
28+
type CachedEntry = {
29+
w: string;
30+
v: string;
31+
s: string;
32+
c: string;
33+
};
34+
35+
function encode(entry: ExternalDeploymentCacheEntry): string {
36+
return JSON.stringify({
37+
w: entry.workerId,
38+
v: entry.version,
39+
s: entry.sdkVersion,
40+
c: entry.cliVersion,
41+
} satisfies CachedEntry);
42+
}
43+
44+
function decode(raw: string): ExternalDeploymentCacheEntry | null {
45+
const parsed: unknown = JSON.parse(raw);
46+
47+
if (typeof parsed !== "object" || parsed === null) {
48+
return null;
49+
}
50+
51+
const { w, v, s, c } = parsed as Partial<CachedEntry>;
52+
53+
if (typeof w !== "string" || typeof v !== "string") {
54+
return null;
55+
}
56+
57+
return {
58+
workerId: w,
59+
version: v,
60+
sdkVersion: typeof s === "string" ? s : "",
61+
cliVersion: typeof c === "string" ? c : "",
62+
};
63+
}
64+
65+
const SET_IF_NEWER_LUA = `
66+
local existing = redis.call("GET", KEYS[1])
67+
68+
if existing then
69+
local ok, decoded = pcall(cjson.decode, existing)
70+
if ok and type(decoded) == "table" and type(decoded.v) == "string" then
71+
local existingDate, existingCounter = string.match(decoded.v, "^([^.]*)%.?(.*)$")
72+
local incomingDate, incomingCounter = string.match(ARGV[2], "^([^.]*)%.?(.*)$")
73+
74+
if existingDate > incomingDate then
75+
return 0
76+
end
77+
78+
if existingDate == incomingDate then
79+
if (tonumber(existingCounter) or 0) >= (tonumber(incomingCounter) or 0) then
80+
return 0
81+
end
82+
end
83+
end
84+
end
85+
86+
redis.call("SET", KEYS[1], ARGV[1], "EX", tonumber(ARGV[3]))
87+
return 1
88+
`;
89+
90+
declare module "ioredis" {
91+
interface RedisCommander<Context> {
92+
skewIdSetIfNewer(
93+
key: string,
94+
entry: string,
95+
version: string,
96+
ttlSeconds: string,
97+
callback?: Callback<number>
98+
): Result<number, Context>;
99+
}
100+
}
101+
102+
export type RedisExternalDeploymentCacheOptions = {
103+
redis: Redis;
104+
ttlSeconds?: number;
105+
};
106+
107+
export class RedisExternalDeploymentCache implements ExternalDeploymentCache {
108+
private readonly redis: Redis;
109+
private readonly ttlSeconds: number;
110+
111+
constructor(options: RedisExternalDeploymentCacheOptions) {
112+
this.redis = options.redis;
113+
this.ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
114+
115+
this.redis.defineCommand("skewIdSetIfNewer", { numberOfKeys: 1, lua: SET_IF_NEWER_LUA });
116+
}
117+
118+
async get(
119+
environmentId: string,
120+
externalId: string
121+
): Promise<ExternalDeploymentCacheEntry | null> {
122+
try {
123+
const raw = await this.redis.get(buildKey(environmentId, externalId));
124+
if (!raw) return null;
125+
return decode(raw);
126+
} catch (error) {
127+
logger.error("Failed to read external deployment resolution from cache", {
128+
environmentId,
129+
externalId,
130+
error,
131+
});
132+
return null;
133+
}
134+
}
135+
136+
async setIfNewer(
137+
environmentId: string,
138+
externalId: string,
139+
entry: ExternalDeploymentCacheEntry
140+
): Promise<void> {
141+
try {
142+
await this.redis.skewIdSetIfNewer(
143+
buildKey(environmentId, externalId),
144+
encode(entry),
145+
entry.version,
146+
String(this.ttlSeconds)
147+
);
148+
} catch (error) {
149+
logger.error("Failed to write external deployment resolution to cache", {
150+
environmentId,
151+
externalId,
152+
version: entry.version,
153+
error,
154+
});
155+
156+
try {
157+
await this.redis.del(buildKey(environmentId, externalId));
158+
} catch (deleteError) {
159+
logger.error("Failed to evict stale external deployment resolution after write failure", {
160+
environmentId,
161+
externalId,
162+
error: deleteError,
163+
});
164+
}
165+
}
166+
}
167+
}
168+
169+
export class NoopExternalDeploymentCache implements ExternalDeploymentCache {
170+
async get(): Promise<ExternalDeploymentCacheEntry | null> {
171+
return null;
172+
}
173+
174+
async setIfNewer(): Promise<void> {}
175+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { defaultReconnectOnError } from "@internal/redis";
2+
import Redis from "ioredis";
3+
import { env } from "~/env.server";
4+
import { singleton } from "~/utils/singleton";
5+
import {
6+
type ExternalDeploymentCache,
7+
NoopExternalDeploymentCache,
8+
RedisExternalDeploymentCache,
9+
} from "./externalDeploymentCache.server";
10+
11+
export const externalDeploymentCacheInstance: ExternalDeploymentCache = singleton(
12+
"externalDeploymentCacheInstance",
13+
initializeExternalDeploymentCache
14+
);
15+
16+
function initializeExternalDeploymentCache(): ExternalDeploymentCache {
17+
if (!env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_HOST) {
18+
return new NoopExternalDeploymentCache();
19+
}
20+
21+
const redis = new Redis({
22+
connectionName: "externalDeploymentCache",
23+
host: env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_HOST,
24+
port: env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_PORT,
25+
username: env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_USERNAME,
26+
password: env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_PASSWORD,
27+
keyPrefix: "tr:",
28+
enableAutoPipelining: true,
29+
reconnectOnError: defaultReconnectOnError,
30+
...(env.EXTERNAL_DEPLOYMENT_CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
31+
});
32+
33+
return new RedisExternalDeploymentCache({
34+
redis,
35+
ttlSeconds: env.EXTERNAL_DEPLOYMENT_CACHE_TTL_SECONDS,
36+
});
37+
}

0 commit comments

Comments
 (0)