import type { BackgroundWorker, BackgroundWorkerTask, Prisma, RuntimeEnvironmentType, TaskQueue, WorkerDeployment, } from "@trigger.dev/database"; import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache"; import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; /** * Cache policy + invalidation for the cross-DB control-plane resolver. * * One-way dependency: this module is imported by `controlPlaneResolver.server.ts`; * it must NEVER import the resolver. The shared `Resolved*` return types live here * so both files reference an identical definition (the resolver re-exports them for * consumers). * * Invalidation note: the underlying `BoundedTtlCache` exposes no public `delete`, so * explicit invalidation is implemented with a per-key epoch map. A write stamps the * stored value with the key's current epoch; a read returns the value only if its * stamped epoch still matches the current epoch, otherwise it is treated as a miss. * `invalidate*` bumps the key's epoch, forcing the next read to miss. (If a future * rebase gives `BoundedTtlCache` a public `delete`, prefer it and drop the epoch map.) * * Two invalidation scopes: `invalidateEnvironment(id)` bumps every env-keyed slot for one * env; `invalidateOrganization(orgId)` bumps a per-org epoch that env/authEnv values are * also stamped with at write time (no reverse org->env index needed), so all of that org's * cached env/authEnv rows miss on the next read. */ export const DEFAULT_CP_CACHE_TTL_MS = 30_000; export const DEFAULT_CP_CACHE_MAX_ENTRIES = 10_000; export type ResolvedEnv = { id: string; type: RuntimeEnvironmentType; projectId: string; organizationId: string; archivedAt: Date | null; // The parent env's type, or null when this env has no parent. Alerts compute // `parentEnvironmentType ?? type` (byte-identical to `parentEnvironment?.type ?? type`). parentEnvironmentType: RuntimeEnvironmentType | null; // Concurrency + nested ids the run-engine ControlPlaneResolver adapter maps to // `ResolvedEngineEnv` (a MinimalAuthenticatedEnvironment superset). Existing app consumers // ignore these additive fields. maximumConcurrencyLimit: number; concurrencyLimitBurstFactor: Prisma.Decimal; }; /** Mirrors `WorkerDeploymentWithWorkerTasks` in `dequeueSystem.ts` exactly. */ export type ResolvedWorkerVersion = { worker: BackgroundWorker; tasks: BackgroundWorkerTask[]; queues: TaskQueue[]; deployment: WorkerDeployment | null; }; // The canonical authenticated-environment shape (slug/type/project/organization/orgMember/…) // PLUS the `git` JSON column the run-engine runAttemptSystem reads. `AuthenticatedEnvironment` // does not carry `git`, so the intersection adds it; this matches the run-engine // `ResolvedAuthenticatedEnv` so the engine adapter can delegate to this cached slot. export type ResolvedAuthenticatedEnv = AuthenticatedEnvironment & { git: Prisma.JsonValue | null }; /** * The slim `lockedBy` (BackgroundWorkerTask) + `lockedToVersion` (BackgroundWorker, with its * WorkerDeployment) shape — the UNION of every field webapp run sites read off these two * cross-DB worker relations. Each field is optional because a run may be locked to a version * but not a task (or neither); resolvers return only what exists. */ export type ResolvedRunLockedWorker = { lockedBy: { id: string; filePath: string; exportName: string | null; slug: string; machineConfig: Prisma.JsonValue | null; worker: { id: string; version: string; sdkVersion: string; cliVersion: string; supportsLazyAttempts: boolean; deployment: { friendlyId: string; shortCode: string; version: string; runtime: string | null; runtimeVersion: string | null; git: Prisma.JsonValue | null; } | null; }; } | null; lockedToVersion: { version: string; sdkVersion: string; runtime: string | null; runtimeVersion: string | null; supportsLazyAttempts: boolean; } | null; }; // `orgEpoch` is stamped only on slots that embed org config (env/authEnv); undefined slots // are exempt from the org-epoch check. type Stamped = { value: V; epoch: number; orgEpoch?: number }; export class ControlPlaneCache { readonly #env: BoundedTtlCache>; readonly #version: BoundedTtlCache>; readonly #envExists: BoundedTtlCache>; readonly #authEnv: BoundedTtlCache>; readonly #lockedWorker: BoundedTtlCache>; // Explicit invalidation: bumping a key's (or org's) epoch forces the next read to miss. readonly #epochs = new Map(); readonly #orgEpochs = new Map(); constructor(opts?: { ttlMs?: number; maxEntries?: number }) { const ttl = opts?.ttlMs ?? DEFAULT_CP_CACHE_TTL_MS; const max = opts?.maxEntries ?? DEFAULT_CP_CACHE_MAX_ENTRIES; this.#env = new BoundedTtlCache(ttl, max); this.#version = new BoundedTtlCache(ttl, max); this.#envExists = new BoundedTtlCache(ttl, max); this.#authEnv = new BoundedTtlCache(ttl, max); this.#lockedWorker = new BoundedTtlCache(ttl, max); } #epoch(key: string): number { return this.#epochs.get(key) ?? 0; } #orgEpoch(orgId: string): number { return this.#orgEpochs.get(orgId) ?? 0; } #read(cache: BoundedTtlCache>, key: string, orgId?: string): V | undefined { const entry = cache.get(key); if (entry === undefined || entry.epoch !== this.#epoch(key)) { return undefined; } if (orgId !== undefined && entry.orgEpoch !== this.#orgEpoch(orgId)) { return undefined; } return entry.value; } #write(cache: BoundedTtlCache>, key: string, value: V, orgId?: string): void { cache.set(key, { value, epoch: this.#epoch(key), orgEpoch: orgId !== undefined ? this.#orgEpoch(orgId) : undefined, }); } #bump(key: string): void { this.#epochs.set(key, this.#epoch(key) + 1); } getEnv(id: string): (ResolvedEnv | null) | undefined { const entry = this.#env.get(`env:${id}`); if (entry === undefined || entry.epoch !== this.#epoch(`env:${id}`)) { return undefined; } // A cached null (or an entry written without an org) carries no org, so it can never be // stale against an org write. if ( entry.value !== null && entry.value.organizationId && entry.orgEpoch !== this.#orgEpoch(entry.value.organizationId) ) { return undefined; } return entry.value; } setEnv(id: string, value: ResolvedEnv | null): void { this.#write(this.#env, `env:${id}`, value, value?.organizationId); } invalidateEnv(id: string): void { this.#bump(`env:${id}`); } // worker version: key = `${environmentId}:${backgroundWorkerId ?? "current"}` getWorkerVersion(key: string): (ResolvedWorkerVersion | null) | undefined { return this.#read(this.#version, `version:${key}`); } setWorkerVersion(key: string, value: ResolvedWorkerVersion | null): void { this.#write(this.#version, `version:${key}`, value); } // env existence (boolean; for the dropped-FK replacement check) getEnvExists(id: string): boolean | undefined { return this.#read(this.#envExists, `envExists:${id}`); } setEnvExists(id: string, exists: boolean): void { this.#write(this.#envExists, `envExists:${id}`, exists); } // full authenticated environment (toAuthenticated shape) getAuthEnv(id: string): (ResolvedAuthenticatedEnv | null) | undefined { const entry = this.#authEnv.get(`authEnv:${id}`); if (entry === undefined || entry.epoch !== this.#epoch(`authEnv:${id}`)) { return undefined; } if ( entry.value !== null && entry.value.organizationId && entry.orgEpoch !== this.#orgEpoch(entry.value.organizationId) ) { return undefined; } return entry.value; } setAuthEnv(id: string, value: ResolvedAuthenticatedEnv | null): void { this.#write(this.#authEnv, `authEnv:${id}`, value, value?.organizationId); } /** * Invalidate every env-keyed slot for a single environment. Call this from a control-plane * write that mutates one env's config (pause/resume, archive, concurrency/burst-factor). */ invalidateEnvironment(id: string): void { this.#bump(`env:${id}`); this.#bump(`authEnv:${id}`); this.#bump(`envExists:${id}`); } /** * Invalidate every cached env/authEnv row belonging to an organization. Call this from a * control-plane write that mutates org-level config (feature flags, org concurrency, runs * enable/disable, rate limits) — it affects the org object embedded in each of the org's envs. */ invalidateOrganization(orgId: string): void { this.#orgEpochs.set(orgId, this.#orgEpoch(orgId) + 1); } // run-locked worker (lockedBy + lockedToVersion); key = `${lockedById ?? "_"}:${lockedToVersionId ?? "_"}` getLockedWorker(key: string): (ResolvedRunLockedWorker | null) | undefined { return this.#read(this.#lockedWorker, `lockedWorker:${key}`); } setLockedWorker(key: string, value: ResolvedRunLockedWorker | null): void { this.#write(this.#lockedWorker, `lockedWorker:${key}`, value); } }