import type { Redis, Result, Callback } from "ioredis"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { logger } from "./logger.server"; export type TaskMetadataEntry = { slug: string; ttl: string | null; triggerSource: TaskTriggerSource; queueId: string | null; queueName: string; }; export interface TaskMetadataCache { /** Read a slug's metadata from the env keyspace (current pointer). */ getCurrent(envId: string, slug: string): Promise; /** Read a slug's metadata from the by-worker keyspace (locked-version lookups). */ getByWorker(workerId: string, slug: string): Promise; /** * Atomically replace both `task-meta:env:{envId}` and * `task-meta:by-worker:{workerId}` with the given entries. Used at deploy * promotion sites where the worker just became current for the env. */ populateByCurrentWorker( envId: string, workerId: string, entries: TaskMetadataEntry[] ): Promise; /** * Replace `task-meta:by-worker:{workerId}` only. Used at deploy build sites * (V4) where the worker is created but not yet promoted. */ populateByWorker(workerId: string, entries: TaskMetadataEntry[]): Promise; /** * Atomically upsert one slug in both keyspaces. Used by the non-locked * read-path back-fill. The env-keyspace TTL is only set when no TTL is * present (preserves the promotion boundary); the by-worker TTL is * refreshed on every call (sliding expiry). */ setByCurrentWorker(envId: string, workerId: string, entry: TaskMetadataEntry): Promise; /** * Upsert one slug in `task-meta:by-worker:{workerId}` only. Used by the * locked-version read-path back-fill; refreshes the by-worker TTL. */ setByWorker(workerId: string, entry: TaskMetadataEntry): Promise; } export type RedisTaskMetadataCacheOptions = { redis: Redis; /** Safety TTL on `task-meta:env:{envId}`. Default 24h. Use 0 for no expiry. */ currentEnvTtlSeconds?: number; /** Idle TTL on `task-meta:by-worker:{workerId}`. Default 30d. Use 0 for no expiry. */ byWorkerTtlSeconds?: number; }; type EncodedEntry = { t: string | null; k: TaskTriggerSource; q: string | null; n: string; }; function encode(entry: TaskMetadataEntry): string { const payload: EncodedEntry = { t: entry.ttl, k: entry.triggerSource, q: entry.queueId, n: entry.queueName, }; return JSON.stringify(payload); } function decode(slug: string, raw: string): TaskMetadataEntry | null { try { const parsed = JSON.parse(raw) as EncodedEntry; return { slug, ttl: parsed.t, triggerSource: parsed.k, queueId: parsed.q, queueName: parsed.n, }; } catch (error) { logger.error("Failed to decode task metadata cache entry", { slug, error }); return null; } } function currentEnvKey(envId: string): string { return `task-meta:env:${envId}`; } function byWorkerKey(workerId: string): string { return `task-meta:by-worker:${workerId}`; } /** * Atomically replace a single HASH's contents and reset its TTL. * * KEYS[1] = hash key * ARGV[1] = ttl seconds (0 = no TTL) * ARGV[2..N] = alternating field, value pairs */ const REPLACE_HASH_LUA = ` redis.call("DEL", KEYS[1]) if #ARGV > 1 then local fv = {} for i = 2, #ARGV do fv[#fv + 1] = ARGV[i] end redis.call("HSET", KEYS[1], unpack(fv)) end local ttl = tonumber(ARGV[1]) if ttl and ttl > 0 then redis.call("EXPIRE", KEYS[1], ttl) end return 1 `; /** * Reserved field name on env hashes that records the worker currently * "owning" the env keyspace. The back-fill Lua script reads this and skips * its env-side write if the owner has flipped — closing the race where a * concurrent promotion atomically replaces the env hash between a resolver's * PG read and its back-fill write. Customer task slugs are kebab/camelCase * and never start with `__`, so collisions are not a concern; an accidental * `getCurrent(envId, "__owner_worker_id")` would JSON.parse-fail and fall * back to PG, not corrupt anything. */ const OWNER_FIELD = "__owner_worker_id"; /** * Atomically replace BOTH keyspaces in one Redis transaction. Used at deploy * promotion — the worker just became current for the env, so the env keyspace * and the worker keyspace get the same field set, and the env hash is * stamped with the new owner workerId. * * KEYS[1] = env hash key * KEYS[2] = by-worker hash key * ARGV[1] = env ttl seconds (0 = no TTL) * ARGV[2] = by-worker ttl seconds (0 = no TTL) * ARGV[3] = workerId (env-hash owner marker) * ARGV[4..N] = alternating field, value pairs (same for both hashes) */ const REPLACE_TWO_HASHES_LUA = ` redis.call("DEL", KEYS[1]) redis.call("DEL", KEYS[2]) if #ARGV > 3 then local fv = {} for i = 4, #ARGV do fv[#fv + 1] = ARGV[i] end redis.call("HSET", KEYS[1], unpack(fv)) redis.call("HSET", KEYS[2], unpack(fv)) end redis.call("HSET", KEYS[1], "${OWNER_FIELD}", ARGV[3]) local envTtl = tonumber(ARGV[1]) if envTtl and envTtl > 0 then redis.call("EXPIRE", KEYS[1], envTtl) end local workerTtl = tonumber(ARGV[2]) if workerTtl and workerTtl > 0 then redis.call("EXPIRE", KEYS[2], workerTtl) end return 1 `; /** * Set a single field and refresh the HASH TTL. Used by the locked-version * back-fill path — sliding expiry keeps active workers warm. * * KEYS[1] = hash key * ARGV[1] = ttl seconds (0 = no TTL refresh) * ARGV[2] = field * ARGV[3] = value */ const SET_FIELD_REFRESH_TTL_LUA = ` redis.call("HSET", KEYS[1], ARGV[2], ARGV[3]) local ttl = tonumber(ARGV[1]) if ttl and ttl > 0 then redis.call("EXPIRE", KEYS[1], ttl) end return 1 `; /** * Atomically upsert one field in BOTH keyspaces. Used by the non-locked * back-fill path. * * The by-worker hash always gets written (the key contains the workerId, so * stale data lands in a dead worker's keyspace and is never read by anyone * not pinned to that version). * * The env hash is CAS-guarded by `${OWNER_FIELD}`: if a concurrent promotion * has replaced the hash between this resolver's PG read and this write, the * stored owner won't match the workerId the back-filler resolved to, so the * env write is skipped — preventing the back-fill from overwriting a freshly * promoted slug with stale data from the previous worker. * * KEYS[1] = env hash key * KEYS[2] = by-worker hash key * ARGV[1] = env ttl seconds (0 = no TTL) * ARGV[2] = by-worker ttl seconds (0 = no TTL) * ARGV[3] = writer's expected env-hash owner workerId * ARGV[4] = field * ARGV[5] = value */ const SET_TWO_FIELDS_LUA = ` redis.call("HSET", KEYS[2], ARGV[4], ARGV[5]) local workerTtl = tonumber(ARGV[2]) if workerTtl and workerTtl > 0 then redis.call("EXPIRE", KEYS[2], workerTtl) end local owner = redis.call("HGET", KEYS[1], "${OWNER_FIELD}") if owner == false or owner == ARGV[3] then redis.call("HSET", KEYS[1], ARGV[4], ARGV[5]) if owner == false then redis.call("HSET", KEYS[1], "${OWNER_FIELD}", ARGV[3]) end local envTtl = tonumber(ARGV[1]) if envTtl and envTtl > 0 and redis.call("TTL", KEYS[1]) == -1 then redis.call("EXPIRE", KEYS[1], envTtl) end end return 1 `; declare module "ioredis" { interface RedisCommander { taskMetaReplaceHash( key: string, ttlSeconds: string, ...fieldValues: string[] ): Result; taskMetaReplaceTwoHashes( envKey: string, workerKey: string, envTtlSeconds: string, workerTtlSeconds: string, workerId: string, ...fieldValues: string[] ): Result; taskMetaSetFieldRefreshTtl( key: string, ttlSeconds: string, field: string, value: string, callback?: Callback ): Result; taskMetaSetTwoFields( envKey: string, workerKey: string, envTtlSeconds: string, workerTtlSeconds: string, workerId: string, field: string, value: string, callback?: Callback ): Result; } } export class RedisTaskMetadataCache implements TaskMetadataCache { private readonly redis: Redis; private readonly currentEnvTtlSeconds: number; private readonly byWorkerTtlSeconds: number; constructor(options: RedisTaskMetadataCacheOptions) { this.redis = options.redis; this.currentEnvTtlSeconds = options.currentEnvTtlSeconds ?? 86400; this.byWorkerTtlSeconds = options.byWorkerTtlSeconds ?? 30 * 24 * 60 * 60; this.redis.defineCommand("taskMetaReplaceHash", { numberOfKeys: 1, lua: REPLACE_HASH_LUA, }); this.redis.defineCommand("taskMetaReplaceTwoHashes", { numberOfKeys: 2, lua: REPLACE_TWO_HASHES_LUA, }); this.redis.defineCommand("taskMetaSetFieldRefreshTtl", { numberOfKeys: 1, lua: SET_FIELD_REFRESH_TTL_LUA, }); this.redis.defineCommand("taskMetaSetTwoFields", { numberOfKeys: 2, lua: SET_TWO_FIELDS_LUA, }); } async getCurrent(envId: string, slug: string): Promise { return this.#get(currentEnvKey(envId), slug); } async getByWorker(workerId: string, slug: string): Promise { return this.#get(byWorkerKey(workerId), slug); } async populateByCurrentWorker( envId: string, workerId: string, entries: TaskMetadataEntry[] ): Promise { try { // Always invoke the script — empty `entries` is valid and causes both // keyspaces to be cleared (DEL + no HSET), which is the right behavior // when promoting a worker with no tasks. const fieldValues: string[] = []; for (const entry of entries) { fieldValues.push(entry.slug, encode(entry)); } await this.redis.taskMetaReplaceTwoHashes( currentEnvKey(envId), byWorkerKey(workerId), String(this.currentEnvTtlSeconds), String(this.byWorkerTtlSeconds), workerId, ...fieldValues ); } catch (error) { logger.error("Failed to populate task metadata cache (current worker)", { envId, workerId, error, }); } } async populateByWorker(workerId: string, entries: TaskMetadataEntry[]): Promise { try { // Always invoke the script — empty `entries` clears the keyspace. const fieldValues: string[] = []; for (const entry of entries) { fieldValues.push(entry.slug, encode(entry)); } await this.redis.taskMetaReplaceHash( byWorkerKey(workerId), String(this.byWorkerTtlSeconds), ...fieldValues ); } catch (error) { logger.error("Failed to populate task metadata cache (by worker)", { workerId, error, }); } } async setByCurrentWorker( envId: string, workerId: string, entry: TaskMetadataEntry ): Promise { try { await this.redis.taskMetaSetTwoFields( currentEnvKey(envId), byWorkerKey(workerId), String(this.currentEnvTtlSeconds), String(this.byWorkerTtlSeconds), workerId, entry.slug, encode(entry) ); } catch (error) { logger.error("Failed to set task metadata cache field (current worker)", { envId, workerId, slug: entry.slug, error, }); } } async setByWorker(workerId: string, entry: TaskMetadataEntry): Promise { try { await this.redis.taskMetaSetFieldRefreshTtl( byWorkerKey(workerId), String(this.byWorkerTtlSeconds), entry.slug, encode(entry) ); } catch (error) { logger.error("Failed to set task metadata cache field (by worker)", { workerId, slug: entry.slug, error, }); } } async #get(key: string, slug: string): Promise { try { const raw = await this.redis.hget(key, slug); if (!raw) return null; return decode(slug, raw); } catch (error) { logger.error("Failed to read task metadata from cache", { key, slug, error }); return null; } } } export class NoopTaskMetadataCache implements TaskMetadataCache { async getCurrent(): Promise { return null; } async getByWorker(): Promise { return null; } async populateByCurrentWorker(): Promise { // intentionally empty } async populateByWorker(): Promise { // intentionally empty } async setByCurrentWorker(): Promise { // intentionally empty } async setByWorker(): Promise { // intentionally empty } }