Files
Oskar Otwinowski 8b0385c429 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).
2026-08-19 17:43:53 +02:00

215 lines
5.4 KiB
TypeScript

import type { Callback, Redis, Result } from "ioredis";
import { logger } from "./logger.server";
export type ExternalDeploymentCacheEntry = {
workerId: string;
version: string;
sdkVersion: string;
cliVersion: string;
};
export type ExternalDeploymentCacheResult =
| { outcome: "deployed"; entry: ExternalDeploymentCacheEntry }
| { outcome: "missing" };
export interface ExternalDeploymentCache {
get(environmentId: string, externalId: string): Promise<ExternalDeploymentCacheResult | null>;
setIfNewer(
environmentId: string,
externalId: string,
entry: ExternalDeploymentCacheEntry
): Promise<void>;
setMissing(environmentId: string, externalId: string): Promise<void>;
}
const KEY_PREFIX = "skewid:";
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
const DEFAULT_MISSING_TTL_SECONDS = 20;
const MISSING_ENTRY = JSON.stringify({ m: 1 });
function buildKey(environmentId: string, externalId: string): string {
return `${KEY_PREFIX}${environmentId}:${externalId}`;
}
type CachedEntry = {
w: string;
v: string;
s: string;
c: string;
};
function encode(entry: ExternalDeploymentCacheEntry): string {
return JSON.stringify({
w: entry.workerId,
v: entry.version,
s: entry.sdkVersion,
c: entry.cliVersion,
} satisfies CachedEntry);
}
function decode(raw: string): ExternalDeploymentCacheResult | null {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) {
return null;
}
const { w, v, s, c, m } = parsed as Partial<CachedEntry> & { m?: unknown };
if (m === 1) {
return { outcome: "missing" };
}
if (typeof w !== "string" || typeof v !== "string") {
return null;
}
return {
outcome: "deployed",
entry: {
workerId: w,
version: v,
sdkVersion: typeof s === "string" ? s : "",
cliVersion: typeof c === "string" ? c : "",
},
};
}
const SET_IF_NEWER_LUA = `
local existing = redis.call("GET", KEYS[1])
if existing then
local ok, decoded = pcall(cjson.decode, existing)
if ok and type(decoded) == "table" and type(decoded.v) == "string" then
local existingDate, existingCounter = string.match(decoded.v, "^([^.]*)%.?(.*)$")
local incomingDate, incomingCounter = string.match(ARGV[2], "^([^.]*)%.?(.*)$")
if existingDate > incomingDate then
return 0
end
if existingDate == incomingDate then
if (tonumber(existingCounter) or 0) >= (tonumber(incomingCounter) or 0) then
return 0
end
end
end
end
redis.call("SET", KEYS[1], ARGV[1], "EX", tonumber(ARGV[3]))
return 1
`;
declare module "ioredis" {
interface RedisCommander<Context> {
skewIdSetIfNewer(
key: string,
entry: string,
version: string,
ttlSeconds: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
export type RedisExternalDeploymentCacheOptions = {
redis: Redis;
ttlSeconds?: number;
missingTtlSeconds?: number;
};
export class RedisExternalDeploymentCache implements ExternalDeploymentCache {
private readonly redis: Redis;
private readonly ttlSeconds: number;
private readonly missingTtlSeconds: number;
constructor(options: RedisExternalDeploymentCacheOptions) {
this.redis = options.redis;
this.ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
this.missingTtlSeconds = options.missingTtlSeconds ?? DEFAULT_MISSING_TTL_SECONDS;
this.redis.defineCommand("skewIdSetIfNewer", { numberOfKeys: 1, lua: SET_IF_NEWER_LUA });
}
async get(
environmentId: string,
externalId: string
): Promise<ExternalDeploymentCacheResult | null> {
try {
const raw = await this.redis.get(buildKey(environmentId, externalId));
if (!raw) return null;
return decode(raw);
} catch (error) {
logger.error("Failed to read external deployment resolution from cache", {
environmentId,
externalId,
error,
});
return null;
}
}
async setIfNewer(
environmentId: string,
externalId: string,
entry: ExternalDeploymentCacheEntry
): Promise<void> {
try {
await this.redis.skewIdSetIfNewer(
buildKey(environmentId, externalId),
encode(entry),
entry.version,
String(this.ttlSeconds)
);
} catch (error) {
logger.error("Failed to write external deployment resolution to cache", {
environmentId,
externalId,
version: entry.version,
error,
});
try {
await this.redis.del(buildKey(environmentId, externalId));
} catch (deleteError) {
logger.error("Failed to evict stale external deployment resolution after write failure", {
environmentId,
externalId,
error: deleteError,
});
}
}
}
async setMissing(environmentId: string, externalId: string): Promise<void> {
try {
await this.redis.set(
buildKey(environmentId, externalId),
MISSING_ENTRY,
"EX",
this.missingTtlSeconds,
"NX"
);
} catch (error) {
logger.error("Failed to write missing external deployment marker to cache", {
environmentId,
externalId,
error,
});
}
}
}
export class NoopExternalDeploymentCache implements ExternalDeploymentCache {
async get(): Promise<ExternalDeploymentCacheResult | null> {
return null;
}
async setIfNewer(): Promise<void> {}
async setMissing(): Promise<void> {}
}