8b0385c429
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).
215 lines
5.4 KiB
TypeScript
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> {}
|
|
}
|