chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ControlPlaneCache,
|
||||
type ResolvedAuthenticatedEnv,
|
||||
type ResolvedEnv,
|
||||
type ResolvedRunLockedWorker,
|
||||
type ResolvedWorkerVersion,
|
||||
} from "./controlPlaneCache.server";
|
||||
|
||||
// Minimal, structurally-irrelevant stand-ins: the cache stores and returns opaque values by
|
||||
// reference, so these only need to be distinguishable objects — the slot types are exercised for
|
||||
// key routing, not field shape.
|
||||
const anEnv = { id: "env_1", organizationId: "org_1" } as unknown as ResolvedEnv;
|
||||
const aVersion = { worker: { id: "bw_1" } } as unknown as ResolvedWorkerVersion;
|
||||
const anAuthEnv = {
|
||||
id: "env_1",
|
||||
slug: "prod",
|
||||
organizationId: "org_1",
|
||||
} as unknown as ResolvedAuthenticatedEnv;
|
||||
const aLockedWorker = { lockedBy: null, lockedToVersion: null } as ResolvedRunLockedWorker;
|
||||
|
||||
describe("ControlPlaneCache", () => {
|
||||
it("round-trips a value through every slot", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_1", anEnv);
|
||||
cache.setWorkerVersion("env_1:current", aVersion);
|
||||
cache.setEnvExists("env_1", true);
|
||||
cache.setAuthEnv("env_1", anAuthEnv);
|
||||
cache.setLockedWorker("bw_1:v_1", aLockedWorker);
|
||||
|
||||
expect(cache.getEnv("env_1")).toBe(anEnv);
|
||||
expect(cache.getWorkerVersion("env_1:current")).toBe(aVersion);
|
||||
expect(cache.getEnvExists("env_1")).toBe(true);
|
||||
expect(cache.getAuthEnv("env_1")).toBe(anAuthEnv);
|
||||
expect(cache.getLockedWorker("bw_1:v_1")).toBe(aLockedWorker);
|
||||
});
|
||||
|
||||
it("returns undefined for a key that was never set, in every slot", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
expect(cache.getEnv("missing")).toBeUndefined();
|
||||
expect(cache.getWorkerVersion("missing")).toBeUndefined();
|
||||
expect(cache.getEnvExists("missing")).toBeUndefined();
|
||||
expect(cache.getAuthEnv("missing")).toBeUndefined();
|
||||
expect(cache.getLockedWorker("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("distinguishes a cached null (confirmed absence) from an unset miss", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
expect(cache.getEnv("env_2")).toBeUndefined();
|
||||
cache.setEnv("env_2", null);
|
||||
expect(cache.getEnv("env_2")).toBeNull();
|
||||
|
||||
expect(cache.getAuthEnv("env_2")).toBeUndefined();
|
||||
cache.setAuthEnv("env_2", null);
|
||||
expect(cache.getAuthEnv("env_2")).toBeNull();
|
||||
|
||||
expect(cache.getWorkerVersion("env_2:current")).toBeUndefined();
|
||||
cache.setWorkerVersion("env_2:current", null);
|
||||
expect(cache.getWorkerVersion("env_2:current")).toBeNull();
|
||||
|
||||
expect(cache.getLockedWorker("_:_")).toBeUndefined();
|
||||
cache.setLockedWorker("_:_", null);
|
||||
expect(cache.getLockedWorker("_:_")).toBeNull();
|
||||
});
|
||||
|
||||
it("caches a false env-existence result distinctly from an unset miss", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
expect(cache.getEnvExists("env_3")).toBeUndefined();
|
||||
cache.setEnvExists("env_3", false);
|
||||
expect(cache.getEnvExists("env_3")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidateEnv forces the next getEnv to miss", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_4", anEnv);
|
||||
expect(cache.getEnv("env_4")).toBe(anEnv);
|
||||
|
||||
cache.invalidateEnv("env_4");
|
||||
expect(cache.getEnv("env_4")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("makes a re-setEnv after invalidation readable again", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
const replacement = { id: "env_5b" } as unknown as ResolvedEnv;
|
||||
|
||||
cache.setEnv("env_5", anEnv);
|
||||
cache.invalidateEnv("env_5");
|
||||
expect(cache.getEnv("env_5")).toBeUndefined();
|
||||
|
||||
cache.setEnv("env_5", replacement);
|
||||
expect(cache.getEnv("env_5")).toBe(replacement);
|
||||
});
|
||||
|
||||
it("invalidateEnv is scoped to its own id", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
const other = { id: "env_keep" } as unknown as ResolvedEnv;
|
||||
|
||||
cache.setEnv("env_drop", anEnv);
|
||||
cache.setEnv("env_keep", other);
|
||||
cache.invalidateEnv("env_drop");
|
||||
|
||||
expect(cache.getEnv("env_drop")).toBeUndefined();
|
||||
expect(cache.getEnv("env_keep")).toBe(other);
|
||||
});
|
||||
|
||||
it("does not collide keys across slots for the same id", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("x", anEnv);
|
||||
cache.setEnvExists("x", true);
|
||||
cache.setAuthEnv("x", anAuthEnv);
|
||||
|
||||
expect(cache.getEnv("x")).toBe(anEnv);
|
||||
expect(cache.getEnvExists("x")).toBe(true);
|
||||
expect(cache.getAuthEnv("x")).toBe(anAuthEnv);
|
||||
|
||||
// Invalidating the env slot leaves the sibling slots for the same id intact.
|
||||
cache.invalidateEnv("x");
|
||||
expect(cache.getEnv("x")).toBeUndefined();
|
||||
expect(cache.getEnvExists("x")).toBe(true);
|
||||
expect(cache.getAuthEnv("x")).toBe(anAuthEnv);
|
||||
});
|
||||
|
||||
it("evicts the oldest entry once maxEntries is exceeded", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 2 });
|
||||
|
||||
cache.setEnv("first", { id: "first" } as unknown as ResolvedEnv);
|
||||
cache.setEnv("second", { id: "second" } as unknown as ResolvedEnv);
|
||||
cache.setEnv("third", { id: "third" } as unknown as ResolvedEnv);
|
||||
|
||||
expect(cache.getEnv("first")).toBeUndefined();
|
||||
expect(cache.getEnv("second")).toMatchObject({ id: "second" });
|
||||
expect(cache.getEnv("third")).toMatchObject({ id: "third" });
|
||||
});
|
||||
|
||||
it("treats a zero-TTL entry as immediately expired", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 0, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_ttl", anEnv);
|
||||
expect(cache.getEnv("env_ttl")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidateEnvironment forces the next env/authEnv/envExists read to miss", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_6", anEnv);
|
||||
cache.setAuthEnv("env_6", anAuthEnv);
|
||||
cache.setEnvExists("env_6", true);
|
||||
expect(cache.getEnv("env_6")).toBe(anEnv);
|
||||
expect(cache.getAuthEnv("env_6")).toBe(anAuthEnv);
|
||||
expect(cache.getEnvExists("env_6")).toBe(true);
|
||||
|
||||
cache.invalidateEnvironment("env_6");
|
||||
|
||||
expect(cache.getEnv("env_6")).toBeUndefined();
|
||||
expect(cache.getAuthEnv("env_6")).toBeUndefined();
|
||||
expect(cache.getEnvExists("env_6")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidateEnvironment is scoped to its own id", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
const keepEnv = { id: "env_keep", organizationId: "org_1" } as unknown as ResolvedEnv;
|
||||
|
||||
cache.setEnv("env_drop", anEnv);
|
||||
cache.setEnv("env_keep", keepEnv);
|
||||
cache.invalidateEnvironment("env_drop");
|
||||
|
||||
expect(cache.getEnv("env_drop")).toBeUndefined();
|
||||
expect(cache.getEnv("env_keep")).toBe(keepEnv);
|
||||
});
|
||||
|
||||
it("invalidateOrganization drops env/authEnv rows for that org across every env id", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
const envA = { id: "env_a", organizationId: "org_1" } as unknown as ResolvedEnv;
|
||||
const envB = { id: "env_b", organizationId: "org_1" } as unknown as ResolvedEnv;
|
||||
const authA = {
|
||||
id: "env_a",
|
||||
slug: "a",
|
||||
organizationId: "org_1",
|
||||
} as unknown as ResolvedAuthenticatedEnv;
|
||||
|
||||
cache.setEnv("env_a", envA);
|
||||
cache.setEnv("env_b", envB);
|
||||
cache.setAuthEnv("env_a", authA);
|
||||
expect(cache.getEnv("env_a")).toBe(envA);
|
||||
expect(cache.getEnv("env_b")).toBe(envB);
|
||||
expect(cache.getAuthEnv("env_a")).toBe(authA);
|
||||
|
||||
cache.invalidateOrganization("org_1");
|
||||
|
||||
// Every env/authEnv row for org_1 misses — no reverse org->env index required.
|
||||
expect(cache.getEnv("env_a")).toBeUndefined();
|
||||
expect(cache.getEnv("env_b")).toBeUndefined();
|
||||
expect(cache.getAuthEnv("env_a")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidateOrganization does not affect a different org's cached envs", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
const otherOrgEnv = { id: "env_other", organizationId: "org_2" } as unknown as ResolvedEnv;
|
||||
|
||||
cache.setEnv("env_1", anEnv); // org_1
|
||||
cache.setEnv("env_other", otherOrgEnv); // org_2
|
||||
|
||||
cache.invalidateOrganization("org_1");
|
||||
|
||||
expect(cache.getEnv("env_1")).toBeUndefined();
|
||||
expect(cache.getEnv("env_other")).toBe(otherOrgEnv);
|
||||
});
|
||||
|
||||
it("re-setting an env after an org invalidation makes it readable again", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_1", anEnv);
|
||||
cache.invalidateOrganization("org_1");
|
||||
expect(cache.getEnv("env_1")).toBeUndefined();
|
||||
|
||||
// A write after the bump stamps the new org epoch, so it reads back.
|
||||
cache.setEnv("env_1", anEnv);
|
||||
expect(cache.getEnv("env_1")).toBe(anEnv);
|
||||
});
|
||||
|
||||
it("a cached null env survives an org invalidation (a confirmed absence carries no org)", () => {
|
||||
const cache = new ControlPlaneCache({ ttlMs: 60_000, maxEntries: 100 });
|
||||
|
||||
cache.setEnv("env_absent", null);
|
||||
expect(cache.getEnv("env_absent")).toBeNull();
|
||||
|
||||
cache.invalidateOrganization("org_1");
|
||||
|
||||
expect(cache.getEnv("env_absent")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
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<V> = { value: V; epoch: number; orgEpoch?: number };
|
||||
|
||||
export class ControlPlaneCache {
|
||||
readonly #env: BoundedTtlCache<Stamped<ResolvedEnv | null>>;
|
||||
readonly #version: BoundedTtlCache<Stamped<ResolvedWorkerVersion | null>>;
|
||||
readonly #envExists: BoundedTtlCache<Stamped<boolean>>;
|
||||
readonly #authEnv: BoundedTtlCache<Stamped<ResolvedAuthenticatedEnv | null>>;
|
||||
readonly #lockedWorker: BoundedTtlCache<Stamped<ResolvedRunLockedWorker | null>>;
|
||||
|
||||
// Explicit invalidation: bumping a key's (or org's) epoch forces the next read to miss.
|
||||
readonly #epochs = new Map<string, number>();
|
||||
readonly #orgEpochs = new Map<string, number>();
|
||||
|
||||
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<V>(cache: BoundedTtlCache<Stamped<V>>, 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<V>(cache: BoundedTtlCache<Stamped<V>>, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type {
|
||||
PrismaClient,
|
||||
PrismaReplicaClient,
|
||||
RuntimeEnvironmentType,
|
||||
} from "@trigger.dev/database";
|
||||
import { prisma, $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ControlPlaneCache,
|
||||
DEFAULT_CP_CACHE_MAX_ENTRIES,
|
||||
DEFAULT_CP_CACHE_TTL_MS,
|
||||
type ResolvedAuthenticatedEnv,
|
||||
type ResolvedEnv,
|
||||
type ResolvedWorkerVersion,
|
||||
type ResolvedRunLockedWorker,
|
||||
} from "./controlPlaneCache.server";
|
||||
import { authIncludeWithParent, toAuthenticated } from "~/models/runtimeEnvironment.server";
|
||||
|
||||
/**
|
||||
* App-level control-plane resolution + cache layer. Replaces the run-ops -> control-plane
|
||||
* Prisma joins (env/project/org, the pinned/current worker version + its tasks/queues, the
|
||||
* TaskQueue, the TaskSchedule friendlyId mapping) with cached lookups against the
|
||||
* control-plane client, so the split (cross-DB) hot path avoids a cross-WAN round-trip per
|
||||
* resolution.
|
||||
*
|
||||
* Split ON (cloud): cache-first reads against the control-plane replica; `null` is cached as
|
||||
* a confirmed absence. Split OFF (self-host/local/CI): plain Prisma join against the single
|
||||
* control-plane client on every call, NO cache — byte-identical to today's inline join.
|
||||
*
|
||||
* The split gate is a SYNCHRONOUS `splitEnabled: () => boolean` injected at construction; the
|
||||
* resolver never awaits the async `isSplitEnabled()` (that gate is reserved for the boot
|
||||
* sentinel). Tests inject testcontainer clients + a sync predicate; only the module-level
|
||||
* singleton at the bottom reads from `db.server.ts` / `env.server.ts`.
|
||||
*
|
||||
* Scope boundary: this unit owns ONLY control-plane resolution (env, worker version,
|
||||
* env existence). The run-ops batchId friendlyId->id resolution belongs to the
|
||||
* run-ops read path (the unit owning `runsRepository.server.ts`); do not duplicate it here.
|
||||
*/
|
||||
|
||||
export { ResolvedEnv, ResolvedWorkerVersion };
|
||||
export type { ResolvedAuthenticatedEnv, ResolvedRunLockedWorker };
|
||||
|
||||
/** Thrown by `assertEnvExists` when a referenced control-plane env does not exist. */
|
||||
export class ControlPlaneReferenceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ControlPlaneReferenceError";
|
||||
}
|
||||
}
|
||||
|
||||
export type ControlPlaneResolverOptions = {
|
||||
controlPlanePrimary: PrismaClient;
|
||||
controlPlaneReplica: PrismaReplicaClient;
|
||||
cache: ControlPlaneCache;
|
||||
splitEnabled: () => boolean;
|
||||
};
|
||||
|
||||
type CpClient = PrismaClient | PrismaReplicaClient;
|
||||
|
||||
function workerVersionKey(
|
||||
environmentId: string,
|
||||
backgroundWorkerId: string | undefined,
|
||||
type: RuntimeEnvironmentType | undefined
|
||||
): string {
|
||||
return `${environmentId}:${backgroundWorkerId ?? "current"}:${type ?? "any"}`;
|
||||
}
|
||||
|
||||
function lockedWorkerKey(lockedById?: string | null, lockedToVersionId?: string | null): string {
|
||||
return `${lockedById ?? "_"}:${lockedToVersionId ?? "_"}`;
|
||||
}
|
||||
|
||||
export class ControlPlaneResolver {
|
||||
private readonly controlPlanePrimary: PrismaClient;
|
||||
private readonly controlPlaneReplica: PrismaReplicaClient;
|
||||
private readonly cache: ControlPlaneCache;
|
||||
private readonly splitEnabled: () => boolean;
|
||||
|
||||
constructor(opts: ControlPlaneResolverOptions) {
|
||||
this.controlPlanePrimary = opts.controlPlanePrimary;
|
||||
this.controlPlaneReplica = opts.controlPlaneReplica;
|
||||
this.cache = opts.cache;
|
||||
this.splitEnabled = opts.splitEnabled;
|
||||
}
|
||||
|
||||
async resolveEnv(environmentId: string): Promise<ResolvedEnv | null> {
|
||||
if (!this.splitEnabled()) {
|
||||
return this.#queryEnv(this.controlPlanePrimary, environmentId);
|
||||
}
|
||||
|
||||
const cached = this.cache.getEnv(environmentId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = await this.#queryEnv(this.controlPlaneReplica, environmentId);
|
||||
this.cache.setEnv(environmentId, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #queryEnv(client: CpClient, environmentId: string): Promise<ResolvedEnv | null> {
|
||||
const env = await client.runtimeEnvironment.findFirst({
|
||||
where: { id: environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
projectId: true,
|
||||
archivedAt: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
concurrencyLimitBurstFactor: true,
|
||||
project: { select: { organizationId: true } },
|
||||
parentEnvironment: { select: { type: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
projectId: env.projectId,
|
||||
organizationId: env.project.organizationId,
|
||||
archivedAt: env.archivedAt,
|
||||
parentEnvironmentType: env.parentEnvironment?.type ?? null,
|
||||
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
|
||||
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveAuthenticatedEnv(environmentId: string): Promise<ResolvedAuthenticatedEnv | null> {
|
||||
if (!this.splitEnabled()) {
|
||||
return this.#queryAuthenticatedEnv(this.controlPlanePrimary, environmentId);
|
||||
}
|
||||
|
||||
const cached = this.cache.getAuthEnv(environmentId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = await this.#queryAuthenticatedEnv(this.controlPlaneReplica, environmentId);
|
||||
this.cache.setAuthEnv(environmentId, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #queryAuthenticatedEnv(
|
||||
client: CpClient,
|
||||
environmentId: string
|
||||
): Promise<ResolvedAuthenticatedEnv | null> {
|
||||
const env = await client.runtimeEnvironment.findFirst({
|
||||
where: { id: environmentId },
|
||||
include: authIncludeWithParent,
|
||||
});
|
||||
|
||||
if (!env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `authIncludeWithParent` returns all RuntimeEnvironment scalars on the row (including
|
||||
// `git`), so we map the auth shape via toAuthenticated() and add `git` from the same row.
|
||||
return { ...toAuthenticated(env), git: env.git };
|
||||
}
|
||||
|
||||
async resolveRunLockedWorker(args: {
|
||||
lockedById?: string | null;
|
||||
lockedToVersionId?: string | null;
|
||||
}): Promise<ResolvedRunLockedWorker | null> {
|
||||
const { lockedById, lockedToVersionId } = args;
|
||||
|
||||
if (!this.splitEnabled()) {
|
||||
return this.#queryRunLockedWorker(this.controlPlanePrimary, lockedById, lockedToVersionId);
|
||||
}
|
||||
|
||||
const key = lockedWorkerKey(lockedById, lockedToVersionId);
|
||||
const cached = this.cache.getLockedWorker(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = await this.#queryRunLockedWorker(
|
||||
this.controlPlaneReplica,
|
||||
lockedById,
|
||||
lockedToVersionId
|
||||
);
|
||||
this.cache.setLockedWorker(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #queryRunLockedWorker(
|
||||
client: CpClient,
|
||||
lockedById?: string | null,
|
||||
lockedToVersionId?: string | null
|
||||
): Promise<ResolvedRunLockedWorker | null> {
|
||||
const lockedByRow = lockedById
|
||||
? await client.backgroundWorkerTask.findFirst({
|
||||
where: { id: lockedById },
|
||||
select: {
|
||||
id: true,
|
||||
filePath: true,
|
||||
exportName: true,
|
||||
slug: true,
|
||||
machineConfig: true,
|
||||
worker: {
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
supportsLazyAttempts: true,
|
||||
deployment: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
shortCode: true,
|
||||
version: true,
|
||||
runtime: true,
|
||||
runtimeVersion: true,
|
||||
git: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const lockedToVersionRow = lockedToVersionId
|
||||
? await client.backgroundWorker.findFirst({
|
||||
where: { id: lockedToVersionId },
|
||||
select: {
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
runtime: true,
|
||||
runtimeVersion: true,
|
||||
supportsLazyAttempts: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
lockedBy: lockedByRow,
|
||||
lockedToVersion: lockedToVersionRow,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveWorkerVersion(args: {
|
||||
environmentId: string;
|
||||
backgroundWorkerId?: string;
|
||||
/**
|
||||
* When provided, the full run-engine dequeue dispatch is used (DEV resolves the most-recent
|
||||
* worker; deployed resolves the promoted MANAGED deployment with the latest-v2 fallback).
|
||||
* When omitted, the original app behavior applies (worker-by-id, else current promotion).
|
||||
*/
|
||||
type?: RuntimeEnvironmentType;
|
||||
}): Promise<ResolvedWorkerVersion | null> {
|
||||
const { environmentId, backgroundWorkerId, type } = args;
|
||||
|
||||
if (!this.splitEnabled()) {
|
||||
return this.#queryWorkerVersion(
|
||||
this.controlPlanePrimary,
|
||||
environmentId,
|
||||
backgroundWorkerId,
|
||||
type
|
||||
);
|
||||
}
|
||||
|
||||
const key = workerVersionKey(environmentId, backgroundWorkerId, type);
|
||||
const cached = this.cache.getWorkerVersion(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolved = await this.#queryWorkerVersion(
|
||||
this.controlPlaneReplica,
|
||||
environmentId,
|
||||
backgroundWorkerId,
|
||||
type
|
||||
);
|
||||
this.cache.setWorkerVersion(key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #queryWorkerVersion(
|
||||
client: CpClient,
|
||||
environmentId: string,
|
||||
backgroundWorkerId?: string,
|
||||
type?: RuntimeEnvironmentType
|
||||
): Promise<ResolvedWorkerVersion | null> {
|
||||
// Full run-engine dequeue dispatch (mirrors dequeueSystem's four helpers) when the env type is
|
||||
// known. DEVELOPMENT envs resolve by most-recent worker; deployed envs resolve the promoted
|
||||
// MANAGED deployment.
|
||||
if (type === "DEVELOPMENT") {
|
||||
return backgroundWorkerId
|
||||
? this.#queryWorkerById(client, backgroundWorkerId)
|
||||
: this.#queryMostRecentWorker(client, environmentId);
|
||||
}
|
||||
|
||||
if (backgroundWorkerId) {
|
||||
const worker = await client.backgroundWorker.findFirst({
|
||||
where: { id: backgroundWorkerId },
|
||||
include: { deployment: true, tasks: true, queues: true },
|
||||
});
|
||||
|
||||
if (!worker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
worker,
|
||||
tasks: worker.tasks,
|
||||
queues: worker.queues,
|
||||
deployment: worker.deployment,
|
||||
};
|
||||
}
|
||||
|
||||
// Deployed env, no workerId: resolve the currently-promoted deployment's worker. When `type`
|
||||
// is known (engine dispatch) apply the MANAGED guard + latest-v2 fallback that the run-engine
|
||||
// path requires; without `type` keep the original app behavior (return the promoted worker).
|
||||
const promotion = await client.workerDeploymentPromotion.findFirst({
|
||||
where: { environmentId, label: CURRENT_DEPLOYMENT_LABEL },
|
||||
include: {
|
||||
deployment: {
|
||||
include: { worker: { include: { tasks: true, queues: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!promotion?.deployment.worker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === undefined || promotion.deployment.type === "MANAGED") {
|
||||
return {
|
||||
worker: promotion.deployment.worker,
|
||||
tasks: promotion.deployment.worker.tasks,
|
||||
queues: promotion.deployment.worker.queues,
|
||||
deployment: promotion.deployment,
|
||||
};
|
||||
}
|
||||
|
||||
// Engine dispatch only: the promoted deployment is not run-engine v2; fall back to the latest
|
||||
// MANAGED deployment.
|
||||
const latestV2Deployment = await client.workerDeployment.findFirst({
|
||||
where: { environmentId, type: "MANAGED" },
|
||||
orderBy: { id: "desc" },
|
||||
include: { worker: { include: { tasks: true, queues: true } } },
|
||||
});
|
||||
|
||||
if (!latestV2Deployment?.worker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
worker: latestV2Deployment.worker,
|
||||
tasks: latestV2Deployment.worker.tasks,
|
||||
queues: latestV2Deployment.worker.queues,
|
||||
deployment: latestV2Deployment,
|
||||
};
|
||||
}
|
||||
|
||||
async #queryWorkerById(
|
||||
client: CpClient,
|
||||
workerId: string
|
||||
): Promise<ResolvedWorkerVersion | null> {
|
||||
const worker = await client.backgroundWorker.findFirst({
|
||||
where: { id: workerId },
|
||||
include: { deployment: true, tasks: true, queues: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
|
||||
if (!worker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { worker, tasks: worker.tasks, queues: worker.queues, deployment: worker.deployment };
|
||||
}
|
||||
|
||||
async #queryMostRecentWorker(
|
||||
client: CpClient,
|
||||
environmentId: string
|
||||
): Promise<ResolvedWorkerVersion | null> {
|
||||
const worker = await client.backgroundWorker.findFirst({
|
||||
where: { runtimeEnvironmentId: environmentId },
|
||||
include: { tasks: true, queues: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
|
||||
if (!worker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { worker, tasks: worker.tasks, queues: worker.queues, deployment: null };
|
||||
}
|
||||
|
||||
async assertEnvExists(environmentId: string): Promise<void> {
|
||||
if (!this.splitEnabled()) {
|
||||
// Split OFF = single DB, so run and env are co-located and there is no FK/check
|
||||
// to replace (matches main). Skip the hot-path read entirely.
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = this.cache.getEnvExists(environmentId);
|
||||
if (cached !== undefined) {
|
||||
if (!cached) {
|
||||
throw new ControlPlaneReferenceError(
|
||||
`Referenced environment does not exist: ${environmentId}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = await this.#queryEnvExists(this.controlPlaneReplica, environmentId);
|
||||
this.cache.setEnvExists(environmentId, exists);
|
||||
if (!exists) {
|
||||
throw new ControlPlaneReferenceError(
|
||||
`Referenced environment does not exist: ${environmentId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #queryEnvExists(client: CpClient, environmentId: string): Promise<boolean> {
|
||||
const env = await client.runtimeEnvironment.findFirst({
|
||||
where: { id: environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
return env !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cached control-plane rows for one environment after a control-plane write to that
|
||||
* env's config. A no-op when split is OFF (nothing is cached), so it is always safe to call.
|
||||
*/
|
||||
invalidateEnvironment(environmentId: string): void {
|
||||
this.cache.invalidateEnvironment(environmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cached env/authEnv rows for every environment of an organization after a
|
||||
* control-plane write to that org's config. Safe under split OFF (no cache).
|
||||
*/
|
||||
invalidateOrganization(organizationId: string): void {
|
||||
this.cache.invalidateOrganization(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton: wires the real control-plane clients + env split predicate.
|
||||
// The control-plane writer/replica are the unchanged `prisma` / `$replica` exports. The
|
||||
// split decision is a boot constant derived once from the env predicate (same one the
|
||||
// run-ops topology factory uses); the async isSplitEnabled() distinct-DB sentinel is enforced
|
||||
// at boot elsewhere and is never awaited on a resolver hot path.
|
||||
const SPLIT_ENABLED =
|
||||
env.RUN_OPS_SPLIT_ENABLED && !!env.RUN_OPS_DATABASE_URL && !!env.RUN_OPS_LEGACY_DATABASE_URL;
|
||||
|
||||
export const controlPlaneResolver = new ControlPlaneResolver({
|
||||
controlPlanePrimary: prisma,
|
||||
controlPlaneReplica: $replica,
|
||||
// Relax the cache via config. Unset env knobs -> built-in defaults (byte-identical).
|
||||
cache: new ControlPlaneCache({
|
||||
ttlMs: env.CONTROL_PLANE_CACHE_TTL_MS ?? DEFAULT_CP_CACHE_TTL_MS,
|
||||
maxEntries: env.CONTROL_PLANE_CACHE_MAX_ENTRIES ?? DEFAULT_CP_CACHE_MAX_ENTRIES,
|
||||
}),
|
||||
splitEnabled: () => SPLIT_ENABLED,
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { isSplitEnabled } from "./splitMode.server";
|
||||
import type {
|
||||
CrossSeamGuardDecision,
|
||||
CrossSeamGuardInput,
|
||||
RunOpsResidency,
|
||||
StoreTarget,
|
||||
UnblockRouteKind,
|
||||
} from "./types";
|
||||
|
||||
const KNOWN_ROUTE_KINDS: ReadonlySet<UnblockRouteKind> = new Set<UnblockRouteKind>([
|
||||
"MANUAL",
|
||||
"DATETIME",
|
||||
"RESUME_TOKEN",
|
||||
"IDEMPOTENCY_REUSE",
|
||||
"RUN",
|
||||
]);
|
||||
|
||||
// There is NO default store: an unrecognised route is a loud failure.
|
||||
function assertKnownRouteKind(routeKind: UnblockRouteKind): void {
|
||||
if (!KNOWN_ROUTE_KINDS.has(routeKind)) {
|
||||
throw new Error(`Unknown unblock routeKind: ${JSON.stringify(routeKind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function storeForResidency(residency: RunOpsResidency): StoreTarget {
|
||||
return residency === "NEW" ? "new" : "legacy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin precedence (deterministic, documented order):
|
||||
* 1. non-tree-owned (treeOwnerResidency === "LEGACY")
|
||||
* 2. cross-tree-idempotency (isCrossTreeIdempotency === true)
|
||||
* 3. legacy-parent-descendant (hasLegacyParent === true)
|
||||
* Any hit overrides the store to "legacy"; the waitpoint's own residency is
|
||||
* preserved on the decision so callers/metrics can see "NEW pinned to legacy".
|
||||
*/
|
||||
function applyPinningRules(
|
||||
input: CrossSeamGuardInput
|
||||
): CrossSeamGuardDecision["pinnedReason"] | undefined {
|
||||
if (input.treeOwnerResidency === "LEGACY") return "non-tree-owned";
|
||||
if (input.isCrossTreeIdempotency === true) return "cross-tree-idempotency";
|
||||
if (input.hasLegacyParent === true) return "legacy-parent-descendant";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure store-selection core. No env import, no I/O — driven exhaustively by the
|
||||
* downstream proof harness via the optional `classify` seam.
|
||||
*/
|
||||
export function selectStoreForWaitpoint(
|
||||
input: CrossSeamGuardInput,
|
||||
deps?: { classify?: (id: string) => RunOpsResidency }
|
||||
): CrossSeamGuardDecision {
|
||||
assertKnownRouteKind(input.routeKind);
|
||||
|
||||
const classify = deps?.classify ?? ownerEngine;
|
||||
|
||||
const residency: RunOpsResidency = classify(input.waitpointId);
|
||||
|
||||
const pinnedReason = applyPinningRules(input);
|
||||
const store: StoreTarget = pinnedReason ? "legacy" : storeForResidency(residency);
|
||||
|
||||
return {
|
||||
store,
|
||||
residency,
|
||||
routeKind: input.routeKind,
|
||||
...(pinnedReason ? { pinnedReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure flag-aware core. In single-DB mode "legacy" IS the single store, so we
|
||||
* return it WITHOUT ever consulting the classifier (off in single-DB). When
|
||||
* split is on, delegate to the pure selection core.
|
||||
*/
|
||||
export function computeStoreForCompletion(
|
||||
input: CrossSeamGuardInput,
|
||||
opts: { splitEnabled: boolean; classify?: (id: string) => RunOpsResidency }
|
||||
): CrossSeamGuardDecision {
|
||||
if (opts.splitEnabled === false) {
|
||||
return { store: "legacy", residency: "LEGACY", routeKind: input.routeKind };
|
||||
}
|
||||
return selectStoreForWaitpoint(input, { classify: opts.classify });
|
||||
}
|
||||
|
||||
/** Thin server entry the waitpoint-completion consumers call. */
|
||||
export async function pickRunOpsStoreForCompletion(
|
||||
input: CrossSeamGuardInput
|
||||
): Promise<CrossSeamGuardDecision> {
|
||||
const splitEnabled = await isSplitEnabled();
|
||||
return computeStoreForCompletion(input, { splitEnabled });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
type DatabaseFingerprint = { systemIdentifier: string; databaseName: string };
|
||||
|
||||
async function readDatabaseFingerprint(url: string): Promise<DatabaseFingerprint> {
|
||||
const client = new PrismaClient({ datasources: { db: { url } } });
|
||||
try {
|
||||
const rows = await client.$queryRawUnsafe<
|
||||
Array<{ system_identifier: string; database_name: string }>
|
||||
>(
|
||||
"SELECT system_identifier::text AS system_identifier, current_database() AS database_name FROM pg_control_system()"
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new Error("distinct-db sentinel: pg_control_system() returned no rows");
|
||||
}
|
||||
return { systemIdentifier: row.system_identifier, databaseName: row.database_name };
|
||||
} finally {
|
||||
await client.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeDistinctDatabases(
|
||||
legacyUrl: string,
|
||||
newUrl: string,
|
||||
opts?: { logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void } }
|
||||
): Promise<{ distinct: true } | { distinct: false; reason: string }> {
|
||||
try {
|
||||
const [legacy, next] = await Promise.all([
|
||||
readDatabaseFingerprint(legacyUrl),
|
||||
readDatabaseFingerprint(newUrl),
|
||||
]);
|
||||
const sameCluster = legacy.systemIdentifier === next.systemIdentifier;
|
||||
const sameDb = sameCluster && legacy.databaseName === next.databaseName;
|
||||
// Same-cluster-different-database policy: two databases inside the SAME cluster
|
||||
// (same system identifier, different current_database()) are reported distinct: true.
|
||||
// That is acceptable — they are genuinely separate Postgres databases with separate
|
||||
// WAL-visible state for our purposes, and the Cloud topology always uses separate
|
||||
// clusters anyway. A stricter "must be a different cluster" policy would gate on
|
||||
// sameCluster alone; that is flagged as an open question, not decided here.
|
||||
if (sameDb) {
|
||||
const reason =
|
||||
"run-ops legacy and new URLs resolve to the SAME physical database " +
|
||||
`(systemIdentifier=${legacy.systemIdentifier}, database=${legacy.databaseName}); ` +
|
||||
"refusing to enable split — pooler/replica likely.";
|
||||
opts?.logger?.warn(reason);
|
||||
return { distinct: false, reason };
|
||||
}
|
||||
return { distinct: true };
|
||||
} catch (error) {
|
||||
const reason = `distinct-db sentinel probe failed; failing closed (single-DB). ${String(error)}`;
|
||||
opts?.logger?.warn(reason, { error });
|
||||
return { distinct: false, reason };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
BatchId,
|
||||
classifyKind,
|
||||
generateRunOpsId,
|
||||
parseRunId,
|
||||
REGION_CODES,
|
||||
} from "@trigger.dev/core/v3/isomorphic";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mintAnchoredRunFriendlyId } from "./mintAnchoredRunFriendlyId.server";
|
||||
|
||||
describe("mintAnchoredRunFriendlyId", () => {
|
||||
it("a run-ops (NEW) batch anchor yields a run-ops (NEW) item friendlyId", () => {
|
||||
const batchFriendlyId = BatchId.toFriendlyId(generateRunOpsId());
|
||||
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId);
|
||||
expect(classifyKind(itemFriendlyId)).toBe("runOpsId");
|
||||
});
|
||||
|
||||
it("a cuid (LEGACY) batch anchor yields a cuid (LEGACY) item friendlyId", () => {
|
||||
const batchFriendlyId = BatchId.generate().friendlyId;
|
||||
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId);
|
||||
expect(classifyKind(itemFriendlyId)).toBe("cuid");
|
||||
});
|
||||
|
||||
it("stamps the requested region char into a run-ops id", () => {
|
||||
const batchFriendlyId = BatchId.toFriendlyId(generateRunOpsId());
|
||||
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId, "us-east-1");
|
||||
const parsed = parseRunId(itemFriendlyId);
|
||||
expect(parsed.format).toBe("b32hex");
|
||||
expect(parsed.format === "b32hex" && parsed.region).toBe(REGION_CODES["us-east-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { generateRunOpsId, RunId, type ResidencyKind } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { resolveInheritedMintKind } from "./resolveInheritedMintKind.server";
|
||||
|
||||
// Shared id-generation branch for every run-mint path: "runOpsId" -> NEW store, "cuid" -> LEGACY.
|
||||
export function mintFriendlyIdForKind(mintKind: ResidencyKind, region?: string): string {
|
||||
return mintKind === "runOpsId"
|
||||
? RunId.toFriendlyId(generateRunOpsId(region))
|
||||
: RunId.generate().friendlyId;
|
||||
}
|
||||
|
||||
// Anchor a batch item's mint on the BATCH's friendlyId (id-shape, zero I/O), never the per-org
|
||||
// flag, so the item and its BatchTaskRun stay co-resident across a mid-batch flag flip.
|
||||
export function mintAnchoredRunFriendlyId(batchFriendlyId: string, region?: string): string {
|
||||
return mintFriendlyIdForKind(resolveInheritedMintKind(batchFriendlyId), region);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { batchIdForMintKind, resolveBatchMintKind } from "./mintBatchFriendlyId.server";
|
||||
import { classifyKind } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
describe("batchIdForMintKind (pure)", () => {
|
||||
it("'runOpsId' kind -> 26-char classifiable NEW batch id (no 21-char ids)", () => {
|
||||
const r = batchIdForMintKind("runOpsId");
|
||||
expect(r.friendlyId.startsWith("batch_")).toBe(true);
|
||||
expect(r.id.length).toBe(26);
|
||||
expect(classifyKind(r.id)).toBe("runOpsId");
|
||||
expect(classifyKind(r.friendlyId)).toBe("runOpsId");
|
||||
});
|
||||
|
||||
it("cuid -> 25-char classifiable LEGACY batch id", () => {
|
||||
const r = batchIdForMintKind("cuid");
|
||||
expect(r.id.length).toBe(25);
|
||||
expect(classifyKind(r.id)).toBe("cuid");
|
||||
expect(classifyKind(r.friendlyId)).toBe("cuid");
|
||||
});
|
||||
|
||||
it("never mints a 21-char id", () => {
|
||||
for (const kind of ["cuid", "runOpsId"] as const) {
|
||||
expect([25, 26]).toContain(batchIdForMintKind(kind).id.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBatchMintKind", () => {
|
||||
const environment = { organizationId: "org_1", id: "env_1", orgFeatureFlags: {} };
|
||||
|
||||
it("ROOT batch (no parent) resolves per-org kind via resolveRunIdMintKind", async () => {
|
||||
const resolveRunIdMintKind = vi.fn().mockResolvedValue("runOpsId");
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
expect(kind).toBe("runOpsId");
|
||||
expect(resolveRunIdMintKind).toHaveBeenCalledWith({
|
||||
organizationId: "org_1",
|
||||
id: "env_1",
|
||||
orgFeatureFlags: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("ROOT batch on a non-cut-over org -> cuid", async () => {
|
||||
const resolveRunIdMintKind = vi.fn().mockResolvedValue("cuid");
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
expect(kind).toBe("cuid");
|
||||
});
|
||||
|
||||
it("CHILD batch inherits a run-ops (NEW) parent by id-shape", async () => {
|
||||
const parentRunFriendlyId = `run_${"a".repeat(24) + "01"}`;
|
||||
const resolveRunIdMintKind = vi.fn();
|
||||
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
parentRunFriendlyId,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
|
||||
expect(kind).toBe("runOpsId");
|
||||
expect(resolveRunIdMintKind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CHILD batch inherits a cuid (LEGACY) parent by id-shape", async () => {
|
||||
const parentRunFriendlyId = `run_${"a".repeat(25)}`;
|
||||
const resolveRunIdMintKind = vi.fn();
|
||||
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
parentRunFriendlyId,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
|
||||
expect(kind).toBe("cuid");
|
||||
expect(resolveRunIdMintKind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// mint-on-FLIP invariant: a child follows its parent's store even after the org flag
|
||||
// flips the other way. The flag resolver must NEVER be consulted for a child.
|
||||
it("FLIP 'cuid'->'runOpsId': a cuid (LEGACY) parent still mints a cuid child though the flag now says 'runOpsId'", async () => {
|
||||
const parentRunFriendlyId = `run_${"a".repeat(25)}`;
|
||||
const resolveRunIdMintKind = vi.fn().mockResolvedValue("runOpsId"); // flag flipped to runOpsId
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
parentRunFriendlyId,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
expect(kind).toBe("cuid");
|
||||
expect(resolveRunIdMintKind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FLIP 'runOpsId'->'cuid': a run-ops (NEW) parent still mints a run-ops child though the flag now says 'cuid'", async () => {
|
||||
const parentRunFriendlyId = `run_${"a".repeat(24) + "01"}`;
|
||||
const resolveRunIdMintKind = vi.fn().mockResolvedValue("cuid"); // flag flipped back to cuid
|
||||
const kind = await resolveBatchMintKind({
|
||||
environment,
|
||||
parentRunFriendlyId,
|
||||
deps: { resolveRunIdMintKind },
|
||||
});
|
||||
expect(kind).toBe("runOpsId");
|
||||
expect(resolveRunIdMintKind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BatchId, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
resolveRunIdMintKind as defaultResolveRunIdMintKind,
|
||||
type RunIdMintKind,
|
||||
} from "~/v3/engineVersion.server";
|
||||
import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server";
|
||||
|
||||
type ResolveDeps = {
|
||||
resolveRunIdMintKind: typeof defaultResolveRunIdMintKind;
|
||||
};
|
||||
|
||||
const defaultDeps: ResolveDeps = {
|
||||
resolveRunIdMintKind: defaultResolveRunIdMintKind,
|
||||
};
|
||||
|
||||
export function batchIdForMintKind(kind: RunIdMintKind): { id: string; friendlyId: string } {
|
||||
if (kind === "runOpsId") {
|
||||
const id = generateRunOpsId();
|
||||
return { id, friendlyId: BatchId.toFriendlyId(id) };
|
||||
}
|
||||
return BatchId.generate();
|
||||
}
|
||||
|
||||
export async function resolveBatchMintKind(args: {
|
||||
environment: { organizationId: string; id: string; orgFeatureFlags?: unknown };
|
||||
parentRunFriendlyId?: string;
|
||||
deps?: Partial<ResolveDeps>;
|
||||
}): Promise<RunIdMintKind> {
|
||||
const deps = { ...defaultDeps, ...args.deps };
|
||||
return args.parentRunFriendlyId
|
||||
? resolveInheritedMintKind(args.parentRunFriendlyId)
|
||||
: deps.resolveRunIdMintKind({
|
||||
organizationId: args.environment.organizationId,
|
||||
id: args.environment.id,
|
||||
orgFeatureFlags: args.environment.orgFeatureFlags,
|
||||
});
|
||||
}
|
||||
|
||||
export async function mintBatchFriendlyId(args: {
|
||||
environment: { organizationId: string; id: string; orgFeatureFlags?: unknown };
|
||||
parentRunFriendlyId?: string;
|
||||
deps?: Partial<ResolveDeps>;
|
||||
}): Promise<{ id: string; friendlyId: string }> {
|
||||
return batchIdForMintKind(await resolveBatchMintKind(args));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Real legacy-replica + new-DB proof for the read-through layer.
|
||||
// We NEVER mock the DB: the reads run as real `$queryRaw` against the two containers,
|
||||
// crossing the actual legacy↔new boundary the split relies on. The only injected
|
||||
// fakes are the pure boundaries — `isPastRetention`, `splitEnabled` — plus throwing
|
||||
// spies used to assert a store was NEVER touched.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import { readThroughRun, type ReadThroughResult } from "./readThrough.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// 25-char cuid body → LEGACY residency. 26-char v1 body (version "1" at index 25) → NEW residency.
|
||||
const LEGACY_RUN_ID = "run_" + "a".repeat(25);
|
||||
const NEW_RUN_ID = "run_" + "b".repeat(24) + "01";
|
||||
|
||||
// Lightweight real read: a trivial `$queryRaw` that genuinely hits the given container.
|
||||
// `hit` controls whether the read "finds" the run, so we exercise routing without
|
||||
// seeding a full TaskRun (many required FKs) — the routing DoD is store-order, not shape.
|
||||
async function realRead(
|
||||
client: PrismaReplicaClient,
|
||||
hit: boolean
|
||||
): Promise<{ marker: number } | null> {
|
||||
const rows = await client.$queryRaw<{ marker: number }[]>`SELECT 1 AS marker`;
|
||||
return hit ? (rows[0] ?? null) : null;
|
||||
}
|
||||
|
||||
// A presenter-shaped mapping: both "not-found" and "past-retention" collapse to the
|
||||
// same 404-ish surface, so an old run after termination yields the normal response.
|
||||
function toHttpish<T>(result: ReadThroughResult<T>): { status: number; value?: T } {
|
||||
switch (result.source) {
|
||||
case "new":
|
||||
case "legacy-replica":
|
||||
return { status: 200, value: result.value };
|
||||
case "not-found":
|
||||
case "past-retention":
|
||||
return { status: 404 };
|
||||
}
|
||||
}
|
||||
|
||||
describe("readThroughRun (legacy replica + new DB)", () => {
|
||||
heteroPostgresTest(
|
||||
"old in-retention run is served from the legacy REPLICA, never a primary",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
// legacy hit, new miss. The layer has NO legacy-writer handle at all — the
|
||||
// read resolving through `legacyReplica` (prisma14) IS the structural guarantee
|
||||
// that the primary is never touched.
|
||||
const result = await readThroughRun({
|
||||
runId: LEGACY_RUN_ID,
|
||||
environmentId: "env_1",
|
||||
readNew: (c) => realRead(c, false),
|
||||
readLegacy: (c) => realRead(c, true),
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.source).toBe("legacy-replica");
|
||||
expect(toHttpish(result).status).toBe(200);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"post-termination past-retention returns the normal not-found surface",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const pastRetentionResult = await readThroughRun({
|
||||
runId: LEGACY_RUN_ID,
|
||||
environmentId: "env_1",
|
||||
readNew: (c) => realRead(c, false),
|
||||
readLegacy: (c) => realRead(c, false), // legacy gone / retention elapsed
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
isPastRetention: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(pastRetentionResult.source).toBe("past-retention");
|
||||
|
||||
// A run that is simply absent (not past retention) yields not-found.
|
||||
const notFoundResult = await readThroughRun({
|
||||
runId: LEGACY_RUN_ID,
|
||||
environmentId: "env_1",
|
||||
readNew: (c) => realRead(c, false),
|
||||
readLegacy: (c) => realRead(c, false),
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
isPastRetention: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(notFoundResult.source).toBe("not-found");
|
||||
// Both collapse to the same 404-ish surface.
|
||||
expect(toHttpish(pastRetentionResult).status).toBe(toHttpish(notFoundResult).status);
|
||||
expect(toHttpish(pastRetentionResult).status).toBe(404);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"single-DB passthrough — only readNew runs, legacy never touched",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const throwingLegacy = vi.fn(async (): Promise<{ marker: number } | null> => {
|
||||
throw new Error("readLegacy must never run in single-DB mode");
|
||||
});
|
||||
const newRead = vi.fn((c: PrismaReplicaClient) => realRead(c, true));
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: LEGACY_RUN_ID,
|
||||
environmentId: "env_1",
|
||||
readNew: newRead,
|
||||
readLegacy: throwingLegacy,
|
||||
deps: {
|
||||
splitEnabled: false,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.source).toBe("new");
|
||||
expect(newRead).toHaveBeenCalledTimes(1);
|
||||
expect(throwingLegacy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"new-residency fast-path — legacy replica is never touched",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const throwingLegacy = vi.fn(async (): Promise<{ marker: number } | null> => {
|
||||
throw new Error("readLegacy must never run for a NEW-residency id");
|
||||
});
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: NEW_RUN_ID,
|
||||
environmentId: "env_1",
|
||||
readNew: (c) => realRead(c, true),
|
||||
readLegacy: throwingLegacy,
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.source).toBe("new");
|
||||
expect(throwingLegacy).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Read-through reads the LEGACY RUN-OPS READ REPLICA ONLY — never the legacy primary
|
||||
* (which carries the read load we are shedding). Disabled entirely when isSplitEnabled()
|
||||
* is false (single-DB passthrough).
|
||||
*
|
||||
* During the retention window, old run-ops rows are served off the legacy read replica.
|
||||
* Residency is decided purely by id-shape: a run-ops id (NEW) id reads new only, a cuid
|
||||
* (LEGACY) id reads legacy only. An unclassifiable id falls back to a new-then-legacy
|
||||
* probe. After termination, past-retention runs return the normal not-found response.
|
||||
* Patterned on `mollifier/resolveRunForMutation.server.ts` (`?? default` DI), but with
|
||||
* the legacy-primary/writer fallback deliberately removed: this layer has NO legacy-writer
|
||||
* handle at all (structural guarantee).
|
||||
*/
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import {
|
||||
runOpsLegacyReplica as defaultLegacyReplica,
|
||||
runOpsNewReplica as defaultNewClient,
|
||||
} from "~/db.server";
|
||||
import { logger as defaultLogger } from "~/services/logger.server";
|
||||
import { ownerEngine, UnclassifiableRunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { isSplitEnabled } from "./splitMode.server";
|
||||
|
||||
export type ReadThroughSource = "new" | "legacy-replica";
|
||||
|
||||
export type ReadThroughResult<T> =
|
||||
| { source: ReadThroughSource; value: T }
|
||||
| { source: "not-found" }
|
||||
| { source: "past-retention" };
|
||||
|
||||
export type ReadThroughDeps = {
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
/** Resolved boot constant; never `await`ed per-request when supplied. */
|
||||
splitEnabled?: boolean;
|
||||
isPastRetention?: (runId: string) => boolean;
|
||||
logger?: { warn: (m: string, meta?: unknown) => void };
|
||||
/** Saturation-signal emit hook: called on each legacy-replica hit. */
|
||||
onLegacyReplicaRead?: (runId: string) => void;
|
||||
};
|
||||
|
||||
type ReadThroughRunInput<T> = {
|
||||
runId: string;
|
||||
environmentId: string;
|
||||
readNew: (client: PrismaReplicaClient) => Promise<T | null>;
|
||||
readLegacy: (replica: PrismaReplicaClient) => Promise<T | null>;
|
||||
deps?: ReadThroughDeps;
|
||||
};
|
||||
|
||||
export async function readThroughRun<T>(
|
||||
input: ReadThroughRunInput<T>
|
||||
): Promise<ReadThroughResult<T>> {
|
||||
const { runId, deps } = input;
|
||||
const newClient = deps?.newClient ?? defaultNewClient;
|
||||
const legacyReplica = deps?.legacyReplica ?? defaultLegacyReplica;
|
||||
const logger = deps?.logger ?? defaultLogger;
|
||||
|
||||
const splitEnabled = deps?.splitEnabled ?? (await isSplitEnabled());
|
||||
|
||||
// Passthrough: single plain read against the one collapsed store. No legacy read,
|
||||
// no second connection.
|
||||
if (!splitEnabled) {
|
||||
const v = await input.readNew(newClient);
|
||||
return v != null ? { source: "new", value: v } : { source: "not-found" };
|
||||
}
|
||||
|
||||
// Split is on. Classify residency; an unclassifiable id is treated as LEGACY
|
||||
// (conservative — probe rather than drop a real run).
|
||||
let residency: "LEGACY" | "NEW";
|
||||
try {
|
||||
residency = ownerEngine(runId);
|
||||
} catch (e) {
|
||||
if (e instanceof UnclassifiableRunId) {
|
||||
logger.warn("readThroughRun: UnclassifiableRunId, treating as LEGACY", {
|
||||
runId,
|
||||
valueLength: e.valueLength,
|
||||
});
|
||||
residency = "LEGACY";
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// A run-ops id can only live on the new DB — skip the legacy replica entirely.
|
||||
if (residency === "NEW") {
|
||||
const v = await input.readNew(newClient);
|
||||
return v != null ? { source: "new", value: v } : { source: "not-found" };
|
||||
}
|
||||
|
||||
// LEGACY (or unclassifiable→LEGACY) fan-out: new first.
|
||||
const v = await input.readNew(newClient);
|
||||
if (v != null) {
|
||||
return { source: "new", value: v };
|
||||
}
|
||||
|
||||
// Legacy READ REPLICA only — never a legacy writer/primary (no such handle exists).
|
||||
const lv = await input.readLegacy(legacyReplica);
|
||||
if (lv != null) {
|
||||
deps?.onLegacyReplicaRead?.(runId);
|
||||
return { source: "legacy-replica", value: lv };
|
||||
}
|
||||
|
||||
if (deps?.isPastRetention?.(runId)) {
|
||||
return { source: "past-retention" };
|
||||
}
|
||||
return { source: "not-found" };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInheritedMintKind } from "./resolveInheritedMintKind.server";
|
||||
|
||||
const NEW_PARENT = `run_${"a".repeat(24) + "01"}`; // run-ops id-shape -> NEW
|
||||
const LEGACY_PARENT = `run_${"b".repeat(25)}`; // cuid id-shape -> LEGACY
|
||||
|
||||
describe("resolveInheritedMintKind (pure id-shape, shared across all mint paths)", () => {
|
||||
it("inherits a run-ops (NEW) parent by id-shape -> 'runOpsId' kind", () => {
|
||||
expect(resolveInheritedMintKind(NEW_PARENT)).toBe("runOpsId");
|
||||
});
|
||||
|
||||
it("inherits a cuid (LEGACY) parent by id-shape -> cuid", () => {
|
||||
expect(resolveInheritedMintKind(LEGACY_PARENT)).toBe("cuid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ownerEngine } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { RunIdMintKind } from "./runOpsMintKind.server";
|
||||
|
||||
// Mint a child in the SAME physical store as its anchor (parent run / owning batch),
|
||||
// regardless of the org's current mint flag — keeps a subgraph co-resident across a
|
||||
// flip. With no migration/drain, residency is a pure id-shape check (zero hot-path
|
||||
// I/O): a run-ops (NEW) parent mints run-ops children, a cuid (LEGACY) parent mints cuid.
|
||||
export function resolveInheritedMintKind(parentRunFriendlyId: string): RunIdMintKind {
|
||||
return ownerEngine(parentRunFriendlyId) === "NEW" ? "runOpsId" : "cuid";
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ControlPlaneResolver as EngineControlPlaneResolver,
|
||||
ResolvedAuthenticatedEnv,
|
||||
ResolvedEngineEnv,
|
||||
ResolvedWorkerVersion,
|
||||
} from "@internal/run-engine";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { ControlPlaneResolver as AppControlPlaneResolver } from "./controlPlaneResolver.server";
|
||||
import { controlPlaneResolver } from "./controlPlaneResolver.server";
|
||||
|
||||
/**
|
||||
* Adapter that presents the webapp's cross-DB cached ControlPlaneResolver as the
|
||||
* run-engine `ControlPlaneResolver` seam. Injected in `runEngine.server.ts`, it replaces the
|
||||
* default `PassthroughControlPlaneResolver` so the engine's dequeue/waitpoint/checkpoint/delayTTL
|
||||
* reads resolve the control-plane half cache-first instead of via an in-DB join.
|
||||
*
|
||||
* `resolveEnv` maps the app `ResolvedEnv` (widened to carry the concurrency + nested ids the engine
|
||||
* needs) onto `ResolvedEngineEnv`. `resolveWorkerVersion` forwards the env `type` so the app
|
||||
* resolver runs the full run-engine dequeue dispatch (DEV most-recent / MANAGED promotion).
|
||||
*/
|
||||
export class RunEngineControlPlaneResolver implements EngineControlPlaneResolver {
|
||||
readonly #resolver: AppControlPlaneResolver;
|
||||
|
||||
constructor(resolver: AppControlPlaneResolver) {
|
||||
this.#resolver = resolver;
|
||||
}
|
||||
|
||||
async resolveEnv(environmentId: string): Promise<ResolvedEngineEnv | null> {
|
||||
const env = await this.#resolver.resolveEnv(environmentId);
|
||||
|
||||
if (!env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
archivedAt: env.archivedAt,
|
||||
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
|
||||
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor,
|
||||
projectId: env.projectId,
|
||||
organizationId: env.organizationId,
|
||||
project: { id: env.projectId },
|
||||
organization: { id: env.organizationId },
|
||||
};
|
||||
}
|
||||
|
||||
async resolveWorkerVersion(args: {
|
||||
environmentId: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
workerId?: string;
|
||||
}): Promise<ResolvedWorkerVersion | null> {
|
||||
return this.#resolver.resolveWorkerVersion({
|
||||
environmentId: args.environmentId,
|
||||
backgroundWorkerId: args.workerId,
|
||||
type: args.type,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveAuthenticatedEnv(environmentId: string): Promise<ResolvedAuthenticatedEnv | null> {
|
||||
// Delegate to the cache-first, split-aware app resolver (like resolveEnv/resolveWorkerVersion):
|
||||
// its authenticated-env slot now carries `git`. Keep the deleted-project guard the engine relies
|
||||
// on — a deleted project's env must not resolve.
|
||||
const environment = await this.#resolver.resolveAuthenticatedEnv(environmentId);
|
||||
|
||||
if (!environment || environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
async assertEnvExists(environmentId: string): Promise<void> {
|
||||
await this.#resolver.assertEnvExists(environmentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton over the app resolver singleton.
|
||||
export const runEngineControlPlaneResolver = new RunEngineControlPlaneResolver(
|
||||
controlPlaneResolver
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache";
|
||||
import { computeRunIdMintKind, type RunIdMintKind } from "./runOpsMintKind.server";
|
||||
|
||||
// LOCK of the CURRENT (intentional) flip-latency behavior, NOT a change request.
|
||||
// resolveRunIdMintKind caches the per-org mint kind in a process-singleton
|
||||
// BoundedTtlCache (TTL RUN_OPS_MINT_FLAG_CACHE_TTL_MS, 30000ms default) with get/set
|
||||
// and NO invalidation hook (runOpsMintKind.server.ts:38-45,56-81). So after a flag
|
||||
// flip a process keeps minting the stale kind until its cached entry expires; in
|
||||
// multi-instance prod each process expires independently. This suite reconstructs the
|
||||
// same flag fn over a real cache and pins both edges of that window.
|
||||
|
||||
// Mirror of resolveRunIdMintKind's flag fn (runOpsMintKind.server.ts:56-81).
|
||||
function makeCachedFlag(
|
||||
cache: BoundedTtlCache<RunIdMintKind>,
|
||||
liveFlag: () => RunIdMintKind
|
||||
): (orgId: string) => Promise<RunIdMintKind> {
|
||||
return async (orgId: string) => {
|
||||
const cached = cache.get(orgId);
|
||||
if (cached !== undefined) return cached;
|
||||
const kind = liveFlag();
|
||||
cache.set(orgId, kind);
|
||||
return kind;
|
||||
};
|
||||
}
|
||||
|
||||
const TTL_MS = 30_000;
|
||||
const env = { organizationId: "org_flip", id: "env_flip" };
|
||||
|
||||
describe("computeRunIdMintKind flip latency (mintCache TTL window — current behavior LOCK)", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("returns the STALE cached kind within the TTL after the flag flips 'cuid'->'runOpsId'", async () => {
|
||||
const cache = new BoundedTtlCache<RunIdMintKind>(TTL_MS, 100);
|
||||
let live: RunIdMintKind = "cuid";
|
||||
const flag = makeCachedFlag(cache, () => live);
|
||||
const deps = { masterEnabled: true, splitEnabled: async () => true, flag };
|
||||
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("cuid"); // populates the cache
|
||||
|
||||
live = "runOpsId"; // admin flips the org flag
|
||||
vi.advanceTimersByTime(TTL_MS - 1); // still inside the window
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("cuid"); // STALE, as designed
|
||||
});
|
||||
|
||||
it("returns the FRESH kind once the TTL expires after a 'cuid'->'runOpsId' flip", async () => {
|
||||
const cache = new BoundedTtlCache<RunIdMintKind>(TTL_MS, 100);
|
||||
let live: RunIdMintKind = "cuid";
|
||||
const flag = makeCachedFlag(cache, () => live);
|
||||
const deps = { masterEnabled: true, splitEnabled: async () => true, flag };
|
||||
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("cuid");
|
||||
|
||||
live = "runOpsId";
|
||||
vi.advanceTimersByTime(TTL_MS + 1); // past expiry -> entry evicted on read
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("runOpsId"); // re-reads the live flag
|
||||
});
|
||||
|
||||
it("symmetric flip-back 'runOpsId'->'cuid' is also stale within TTL, fresh after", async () => {
|
||||
const cache = new BoundedTtlCache<RunIdMintKind>(TTL_MS, 100);
|
||||
let live: RunIdMintKind = "runOpsId";
|
||||
const flag = makeCachedFlag(cache, () => live);
|
||||
const deps = { masterEnabled: true, splitEnabled: async () => true, flag };
|
||||
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("runOpsId");
|
||||
|
||||
live = "cuid";
|
||||
vi.advanceTimersByTime(TTL_MS - 1);
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("runOpsId"); // STALE
|
||||
|
||||
vi.advanceTimersByTime(2); // now past expiry
|
||||
expect(await computeRunIdMintKind(env, deps)).toBe("cuid"); // FRESH
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { computeRunIdMintKind } from "./runOpsMintKind.server";
|
||||
|
||||
describe("computeRunIdMintKind (pure)", () => {
|
||||
it("mints cuid when the master switch is off (never reads the flag)", async () => {
|
||||
const flag = vi.fn();
|
||||
const kind = await computeRunIdMintKind(
|
||||
{ organizationId: "org_1", id: "env_1" },
|
||||
{ masterEnabled: false, splitEnabled: async () => true, flag }
|
||||
);
|
||||
expect(kind).toBe("cuid");
|
||||
expect(flag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints cuid when split is OFF, even if master + per-org flag say 'runOpsId'", async () => {
|
||||
const flag = vi.fn().mockResolvedValue("runOpsId");
|
||||
const kind = await computeRunIdMintKind(
|
||||
{ organizationId: "org_1", id: "env_1" },
|
||||
{ masterEnabled: true, splitEnabled: async () => false, flag }
|
||||
);
|
||||
expect(kind).toBe("cuid"); // the split-enabled gate dominates
|
||||
expect(flag).not.toHaveBeenCalled(); // split-off short-circuits before any flag read
|
||||
});
|
||||
|
||||
it("mints run-ops id only when master on AND split on AND per-org flag = 'runOpsId'", async () => {
|
||||
const flag = vi.fn().mockResolvedValue("runOpsId");
|
||||
const kind = await computeRunIdMintKind(
|
||||
{ organizationId: "org_1", id: "env_1" },
|
||||
{ masterEnabled: true, splitEnabled: async () => true, flag }
|
||||
);
|
||||
expect(kind).toBe("runOpsId");
|
||||
});
|
||||
|
||||
it("passes the already-loaded org feature flags through to the flag fn (no extra DB read)", async () => {
|
||||
const flag = vi.fn().mockResolvedValue("runOpsId");
|
||||
const orgFeatureFlags = { runOpsMintKind: "runOpsId" };
|
||||
await computeRunIdMintKind(
|
||||
{ organizationId: "org_1", id: "env_1", orgFeatureFlags },
|
||||
{ masterEnabled: true, splitEnabled: async () => true, flag }
|
||||
);
|
||||
expect(flag).toHaveBeenCalledWith("org_1", orgFeatureFlags);
|
||||
});
|
||||
|
||||
it("mints cuid for a non-canary org (per-org flag defaults to cuid)", async () => {
|
||||
const flag = vi.fn().mockResolvedValue("cuid");
|
||||
const kind = await computeRunIdMintKind(
|
||||
{ organizationId: "org_2", id: "env_2" },
|
||||
{ masterEnabled: true, splitEnabled: async () => true, flag }
|
||||
);
|
||||
expect(kind).toBe("cuid");
|
||||
});
|
||||
|
||||
it("fails safe to cuid when the flag read throws", async () => {
|
||||
const flag = vi.fn().mockRejectedValue(new Error("db down"));
|
||||
const kind = await computeRunIdMintKind(
|
||||
{ organizationId: "org_1", id: "env_1" },
|
||||
{ masterEnabled: true, splitEnabled: async () => true, flag }
|
||||
);
|
||||
expect(kind).toBe("cuid"); // never arm a mint on a flag-read failure
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { FEATURE_FLAG } from "~/v3/featureFlags";
|
||||
import { makeFlag } from "~/v3/featureFlags.server";
|
||||
import { isSplitEnabled } from "./splitMode.server";
|
||||
|
||||
export type RunIdMintKind = "cuid" | "runOpsId";
|
||||
|
||||
type MintKindDeps = {
|
||||
masterEnabled: boolean;
|
||||
splitEnabled: () => Promise<boolean>;
|
||||
// Receives the orgId + the (optional) already-loaded org feature flags. When
|
||||
// orgFeatureFlags is provided, the implementation must NOT read the DB for them.
|
||||
flag: (orgId: string, orgFeatureFlags: unknown | undefined) => Promise<RunIdMintKind>;
|
||||
};
|
||||
|
||||
// PURE CORE — no env import; tests drive this directly. Gate order is load-bearing:
|
||||
// master switch → split gate → per-org flag, short-circuiting at the first OFF.
|
||||
export async function computeRunIdMintKind(
|
||||
environment: { organizationId: string; id: string; orgFeatureFlags?: unknown },
|
||||
deps: MintKindDeps
|
||||
): Promise<RunIdMintKind> {
|
||||
if (!deps.masterEnabled) return "cuid";
|
||||
if (!(await deps.splitEnabled())) return "cuid";
|
||||
try {
|
||||
return await deps.flag(environment.organizationId, environment.orgFeatureFlags);
|
||||
} catch (error) {
|
||||
logger.error("[runOpsMintKind] flag read failed; minting cuid (fail-safe)", { error });
|
||||
return "cuid";
|
||||
}
|
||||
}
|
||||
|
||||
// ENV-BOUND wrapper — the only place env/$replica/isSplitEnabled are read.
|
||||
const flagFn = singleton("runOpsMintFlag", () => makeFlag($replica));
|
||||
const mintCache = singleton(
|
||||
"runOpsMintCache",
|
||||
() =>
|
||||
new BoundedTtlCache<RunIdMintKind>(
|
||||
env.RUN_OPS_MINT_FLAG_CACHE_TTL_MS,
|
||||
env.RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES
|
||||
)
|
||||
);
|
||||
|
||||
export async function resolveRunIdMintKind(environment: {
|
||||
organizationId: string;
|
||||
id: string;
|
||||
// Pass environment.organization.featureFlags from the trigger call site.
|
||||
orgFeatureFlags?: unknown;
|
||||
}): Promise<RunIdMintKind> {
|
||||
return computeRunIdMintKind(environment, {
|
||||
masterEnabled: env.RUN_OPS_MINT_ENABLED,
|
||||
splitEnabled: isSplitEnabled,
|
||||
flag: async (orgId, orgFeatureFlags) => {
|
||||
// The cache stores only "cuid"|"runOpsId" (never undefined), so the cache's
|
||||
// "stored-undefined == miss" caveat never applies here.
|
||||
const cached = mintCache.get(orgId);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// Hot-path pass-through: use the org flags the authenticated environment already
|
||||
// carries; only fall back to a DB read when the caller did NOT pass them (non-trigger
|
||||
// callers). The trigger path always passes them, so it never issues this findFirst.
|
||||
const overrides =
|
||||
orgFeatureFlags !== undefined
|
||||
? orgFeatureFlags
|
||||
: (
|
||||
await $replica.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: { featureFlags: true },
|
||||
})
|
||||
)?.featureFlags;
|
||||
|
||||
const kind = await flagFn({
|
||||
key: FEATURE_FLAG.runOpsMintKind,
|
||||
defaultValue: "cuid",
|
||||
overrides: (overrides as Record<string, unknown>) ?? {},
|
||||
});
|
||||
mintCache.set(orgId, kind);
|
||||
return kind;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Pure run-ops split READ gate. The LEGACY handle is intentionally the control-plane client,
|
||||
// so only the NEW client's distinctness gates (see runOpsSplitReadGate.test.ts).
|
||||
export function computeRunOpsSplitReadEnabled(args: {
|
||||
newReplica: unknown;
|
||||
controlPlaneWriter: unknown;
|
||||
controlPlaneReplica: unknown;
|
||||
hasNewUrl: boolean;
|
||||
hasLegacyUrl: boolean;
|
||||
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
}): boolean {
|
||||
const newIsDistinctDedicatedClient =
|
||||
args.newReplica !== args.controlPlaneWriter && args.newReplica !== args.controlPlaneReplica;
|
||||
|
||||
const enabled = newIsDistinctDedicatedClient && args.hasNewUrl && args.hasLegacyUrl;
|
||||
|
||||
// Configured for split but the identity check failed: fan-out is being silently disabled.
|
||||
if (!newIsDistinctDedicatedClient && args.hasNewUrl && args.hasLegacyUrl) {
|
||||
args.logger?.warn(
|
||||
"run-ops split read fan-out is configured (RUN_OPS_DATABASE_URL and " +
|
||||
"RUN_OPS_LEGACY_DATABASE_URL are both set) but the NEW client is not a distinct " +
|
||||
"instance from the control-plane client; read fan-out is silently disabled."
|
||||
);
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* isSplitEnabled() is the Wave-0 gate. The entire migration/routing/FK-drop family
|
||||
* MUST be unreachable when this returns false. Default is false (single-DB). Never
|
||||
* infer split-vs-single from URL string-equality — distinctness is proven by the
|
||||
* runtime sentinel.
|
||||
*/
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { probeDistinctDatabases as defaultProbe } from "./distinctDbSentinel.server";
|
||||
|
||||
export type SplitModeConfig = {
|
||||
flagEnabled: boolean;
|
||||
legacyUrl?: string;
|
||||
newUrl?: string;
|
||||
};
|
||||
|
||||
export type SplitModeDeps = {
|
||||
probe?: typeof defaultProbe;
|
||||
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
|
||||
};
|
||||
|
||||
export async function computeSplitEnabled(
|
||||
config: SplitModeConfig,
|
||||
deps: SplitModeDeps = {}
|
||||
): Promise<boolean> {
|
||||
// Hard gate #1: explicit positive opt-in. OFF by default -> never probe.
|
||||
if (!config.flagEnabled) {
|
||||
return false;
|
||||
}
|
||||
// Both URLs are required to even consider a split.
|
||||
if (!config.legacyUrl || !config.newUrl) {
|
||||
deps.logger?.warn(
|
||||
"RUN_OPS_SPLIT_ENABLED is on but RUN_OPS_LEGACY_DATABASE_URL / RUN_OPS_DATABASE_URL are not both set; staying single-DB."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Hard gate #2: runtime sentinel must confirm physically-distinct DBs.
|
||||
const probe = deps.probe ?? defaultProbe;
|
||||
const result = await probe(config.legacyUrl, config.newUrl, { logger: deps.logger });
|
||||
return result.distinct === true;
|
||||
}
|
||||
|
||||
export type SplitRealtimeInterlockConfig = {
|
||||
splitEnabled: boolean;
|
||||
nativeRealtimeEnabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Boot-time realtime interlock (pure predicate). Split mode puts NEW-resident
|
||||
* (run-ops id) runs on the dedicated run-ops DB, but Electric replicates only from the
|
||||
* control-plane DB — with the native realtime backend OFF those runs are invisible
|
||||
* and every realtime subscription hangs. Refuse split unless native is on; split-off
|
||||
* is always allowed regardless of the realtime backend.
|
||||
*/
|
||||
export function assertSplitRealtimeInterlock(config: SplitRealtimeInterlockConfig): void {
|
||||
if (!config.splitEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!config.nativeRealtimeEnabled) {
|
||||
throw new Error(
|
||||
"RUN_OPS_SPLIT_ENABLED is on but the native realtime backend (REALTIME_BACKEND_NATIVE_ENABLED) is not enabled — Electric cannot serve NEW-resident runs; refusing to enable split."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let cached: Promise<boolean> | undefined;
|
||||
|
||||
export function isSplitEnabled(): Promise<boolean> {
|
||||
if (!cached) {
|
||||
cached = computeSplitEnabled(
|
||||
{
|
||||
flagEnabled: env.RUN_OPS_SPLIT_ENABLED,
|
||||
legacyUrl: env.RUN_OPS_LEGACY_DATABASE_URL,
|
||||
newUrl: env.RUN_OPS_DATABASE_URL,
|
||||
},
|
||||
{ logger }
|
||||
);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function __resetSplitModeCacheForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Pure types for the cross-seam residency guard. No runtime, no env, no Prisma.
|
||||
import type { Residency } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
// Aliased (not re-declared) so it cannot drift from the classifier's own union.
|
||||
export type RunOpsResidency = Residency;
|
||||
|
||||
export type StoreTarget = "new" | "legacy";
|
||||
|
||||
export type UnblockRouteKind = "MANUAL" | "DATETIME" | "RESUME_TOKEN" | "IDEMPOTENCY_REUSE" | "RUN";
|
||||
|
||||
export interface CrossSeamGuardInput {
|
||||
waitpointId: string;
|
||||
routeKind: UnblockRouteKind;
|
||||
treeOwnerResidency?: RunOpsResidency;
|
||||
isCrossTreeIdempotency?: boolean;
|
||||
hasLegacyParent?: boolean;
|
||||
}
|
||||
|
||||
export interface CrossSeamGuardDecision {
|
||||
store: StoreTarget;
|
||||
/** Always the waitpoint's OWN classification, even when pinned to legacy. */
|
||||
residency: RunOpsResidency;
|
||||
routeKind: UnblockRouteKind;
|
||||
pinnedReason?: "non-tree-owned" | "cross-tree-idempotency" | "legacy-parent-descendant";
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// If you add a `completeWaitpoint(` call site in the run-engine, add a matching
|
||||
// entry here or `apps/webapp/test/crossSeamGuard.proof.test.ts` fails. Entries are
|
||||
// one-per-textual-call-site (so the per-file count matches the source), anchored by
|
||||
// method name, not line number. The `kind` is the dominant route kind — store
|
||||
// selection is driven by residency, not kind, so a disputed kind label is cosmetic.
|
||||
//
|
||||
// PURE module — no engine import, no env, no Prisma.
|
||||
import type { UnblockRouteKind } from "./types";
|
||||
|
||||
export interface UnblockRoute {
|
||||
id: string;
|
||||
kind: UnblockRouteKind;
|
||||
/** The relative source path, e.g. "internal-packages/run-engine/src/engine/index.ts". */
|
||||
site: string;
|
||||
/** Enclosing method/symbol name — NEVER a line number. */
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
const INDEX = "internal-packages/run-engine/src/engine/index.ts";
|
||||
const WAITPOINT_SYSTEM = "internal-packages/run-engine/src/engine/systems/waitpointSystem.ts";
|
||||
const TTL_SYSTEM = "internal-packages/run-engine/src/engine/systems/ttlSystem.ts";
|
||||
const RUN_ATTEMPT_SYSTEM = "internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts";
|
||||
const BATCH_SYSTEM = "internal-packages/run-engine/src/engine/systems/batchSystem.ts";
|
||||
|
||||
export const UNBLOCK_ROUTES: readonly UnblockRoute[] = [
|
||||
{
|
||||
id: "index.public",
|
||||
kind: "RESUME_TOKEN",
|
||||
site: INDEX,
|
||||
symbol: "completeWaitpoint (public declaration)",
|
||||
},
|
||||
{
|
||||
id: "index.public.delegate",
|
||||
kind: "RESUME_TOKEN",
|
||||
site: INDEX,
|
||||
symbol: "completeWaitpoint (delegation to waitpointSystem)",
|
||||
},
|
||||
{
|
||||
id: "index.finishWaitpoint",
|
||||
kind: "DATETIME",
|
||||
site: INDEX,
|
||||
symbol: "finishWaitpoint redis job",
|
||||
},
|
||||
{
|
||||
id: "wp.sink",
|
||||
kind: "RUN",
|
||||
site: WAITPOINT_SYSTEM,
|
||||
symbol: "completeWaitpoint (sink declaration)",
|
||||
},
|
||||
{
|
||||
id: "wp.blockAndComplete",
|
||||
kind: "RUN",
|
||||
site: WAITPOINT_SYSTEM,
|
||||
symbol: "blockRunAndCompleteWaitpoint",
|
||||
},
|
||||
{
|
||||
id: "wp.getOrCreate",
|
||||
kind: "IDEMPOTENCY_REUSE",
|
||||
site: WAITPOINT_SYSTEM,
|
||||
symbol: "getOrCreateRunWaitpoint",
|
||||
},
|
||||
{
|
||||
id: "batch.tryCompleteBatch",
|
||||
kind: "RUN",
|
||||
site: BATCH_SYSTEM,
|
||||
symbol: "#tryCompleteBatch",
|
||||
},
|
||||
{
|
||||
id: "ttl.expireRun",
|
||||
kind: "RUN",
|
||||
site: TTL_SYSTEM,
|
||||
symbol: "expireRun",
|
||||
},
|
||||
{
|
||||
id: "runAttempt.succeeded",
|
||||
kind: "RUN",
|
||||
site: RUN_ATTEMPT_SYSTEM,
|
||||
symbol: "attemptSucceeded",
|
||||
},
|
||||
{
|
||||
id: "runAttempt.cancel",
|
||||
kind: "RUN",
|
||||
site: RUN_ATTEMPT_SYSTEM,
|
||||
symbol: "cancelRun",
|
||||
},
|
||||
{
|
||||
id: "runAttempt.permanentlyFail",
|
||||
kind: "RUN",
|
||||
site: RUN_ATTEMPT_SYSTEM,
|
||||
symbol: "#permanentlyFailRun",
|
||||
},
|
||||
];
|
||||
|
||||
export function expectedCompleteWaitpointCallSites(): { site: string; symbol: string }[] {
|
||||
return UNBLOCK_ROUTES.map((r) => ({ site: r.site, symbol: r.symbol }));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// A standalone wait token is minted with a cuid id and, having no owning run, is written to the
|
||||
// control-plane store. Under the split topology the run-ops read replica is a distinct database
|
||||
// that does not hold it, so the public token routes must fan out to the control-plane replica or
|
||||
// the token is reported missing. These reads run as real queries against two containers.
|
||||
import { heteroPostgresTest } from "@internal/testcontainers";
|
||||
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import { resolveWaitpointThroughReadThrough } from "~/runEngine/concerns/resolveWaitpointThroughReadThrough.server";
|
||||
import { readThroughRun } from "./readThrough.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
async function seedControlPlaneEnv(prisma: PrismaClient, suffix: string) {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `Org ${suffix}`, slug: `org-${suffix}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `Project ${suffix}`,
|
||||
slug: `project-${suffix}`,
|
||||
externalRef: `proj_${suffix}`,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const environment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
type: "PRODUCTION",
|
||||
slug: `prod-${suffix}`,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_prod_${suffix}`,
|
||||
pkApiKey: `pk_prod_${suffix}`,
|
||||
shortcode: `short_${suffix}`,
|
||||
maximumConcurrencyLimit: 10,
|
||||
},
|
||||
});
|
||||
return { organization, project, environment };
|
||||
}
|
||||
|
||||
async function seedStandaloneToken(prisma: PrismaClient, environmentId: string, projectId: string) {
|
||||
const { id, friendlyId } = WaitpointId.generate();
|
||||
await prisma.waitpoint.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
type: "MANUAL",
|
||||
status: "PENDING",
|
||||
idempotencyKey: id,
|
||||
userProvidedIdempotencyKey: false,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
return { id, friendlyId };
|
||||
}
|
||||
|
||||
describe("public wait-token resolution across the split boundary", () => {
|
||||
heteroPostgresTest(
|
||||
"a control-plane-resident standalone token is found when the run-ops replica does not hold it",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { project, environment } = await seedControlPlaneEnv(prisma14, "token_cp");
|
||||
const { id: waitpointId } = await seedStandaloneToken(prisma14, environment.id, project.id);
|
||||
|
||||
const waitpoint = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: environment.id,
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: { id: waitpointId, environmentId: environment.id },
|
||||
}),
|
||||
deps: {
|
||||
// Pin split-on explicitly: without it the fan-out gate falls back to the
|
||||
// ambient RUN_OPS_SPLIT_ENABLED env, which is unset in CI (single-DB
|
||||
// passthrough reads only the run-ops replica and never fans out to the
|
||||
// control-plane legacy replica that holds this cuid-shaped token).
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(waitpoint).not.toBeNull();
|
||||
expect(waitpoint?.id).toBe(waitpointId);
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"pinning both reads at the run-ops replica misses the control-plane token",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { project, environment } = await seedControlPlaneEnv(prisma14, "token_miss");
|
||||
const { id: waitpointId } = await seedStandaloneToken(prisma14, environment.id, project.id);
|
||||
|
||||
const waitpoint = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: environment.id,
|
||||
read: (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: { id: waitpointId, environmentId: environment.id },
|
||||
}),
|
||||
deps: {
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma17 as unknown as PrismaReplicaClient,
|
||||
// The run-ops-primary fallback is also pinned at run-ops, which does not hold this
|
||||
// control-plane token, so the miss stays a miss (fallback never reads the control-plane).
|
||||
newPrimary: prisma17 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(waitpoint).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
heteroPostgresTest(
|
||||
"the read gate forces fan-out so a control-plane token resolves while the mint flag is off",
|
||||
async ({ prisma14, prisma17 }) => {
|
||||
const { project, environment } = await seedControlPlaneEnv(prisma14, "token_gate");
|
||||
const { id: waitpointId } = await seedStandaloneToken(prisma14, environment.id, project.id);
|
||||
|
||||
const read = (client: PrismaReplicaClient) =>
|
||||
client.waitpoint.findFirst({
|
||||
where: { id: waitpointId, environmentId: environment.id },
|
||||
});
|
||||
|
||||
const gated = await resolveWaitpointThroughReadThrough({
|
||||
waitpointId,
|
||||
environmentId: environment.id,
|
||||
read,
|
||||
deps: {
|
||||
splitEnabled: true,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(gated).not.toBeNull();
|
||||
expect(gated?.id).toBe(waitpointId);
|
||||
|
||||
const passthrough = await readThroughRun({
|
||||
runId: waitpointId,
|
||||
environmentId: environment.id,
|
||||
readNew: (c) => read(c),
|
||||
readLegacy: (r) => read(r),
|
||||
deps: {
|
||||
splitEnabled: false,
|
||||
newClient: prisma17 as unknown as PrismaReplicaClient,
|
||||
legacyReplica: prisma14 as unknown as PrismaReplicaClient,
|
||||
},
|
||||
});
|
||||
|
||||
expect(gated).not.toBeNull();
|
||||
expect(passthrough.source).toBe("not-found");
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user