092b9ef07a
## Problem
The run-ops split mints NEW-store run ids as **27-char base62 KSUIDs**.
The supervisor writes the run id into the Kubernetes pod name
(`runner-<id>`), and pod names must be DNS-1123 labels (lowercase
`[a-z0-9-]`) — so uppercase base62 ids make k8s reject the pod (422) and
**those runs never launch** (they loop in `PENDING_EXECUTING` until the
heartbeat-stall handler nacks them, forever). `.toLowerCase()` can't fix
it: base62 has both `A`(10) and `a`(36) as distinct symbols, so folding
collides distinct ids and destroys sort order.
## Fix: change the encoding, not the structure
Mint a **26-char lowercase base32hex** run id:
```
run_<24-char base32hex core><region char><version char>
[ 6-byte ms timestamp ][ 9 CSPRNG bytes ]
```
- **base32hex** (RFC 4648 §7, alphabet `0-9a-v`): lowercase,
order-preserving, DNS-safe; 15 bytes → exactly 24 chars, no padding.
Hand-rolled encode/decode (no new dependency).
- **48-bit ms timestamp** in the leading bytes → plain string sort ==
creation order at millisecond resolution.
- **72 bits CSPRNG** entropy; PK unique constraint is the backstop (no
retry loop).
- **region / version** are raw positional chars (read via one `charAt`
before decoding/routing), version = `"1"`.
DNS-safe from birth and hyphen-free, so **firekeeper is unchanged** —
`runner-<id>-attempt-N` → strip `runner-`, cut at first hyphen still
recovers the exact id incl. region+version.
## Residency discriminator: length → version char
`classifyKind`/`classifyResidency` (`runOpsResidency.ts`) previously
distinguished NEW vs LEGACY by **id length**. That gets ambiguous with a
third format. It now discriminates on the **version char at a fixed
position** (`isRunOpsIdBody`: 26 chars, `[25] === "1"`, base32hex
alphabet) → NEW; everything else → LEGACY. Total, never throws. The
`Residency` (NEW/LEGACY) contract the routing store consumes is
unchanged; the `"ksuid"` `ResidencyKind` label is retained only because
it's the persisted `runOpsMintKsuid` feature-flag value.
## Scope / verification
- Generator + discriminator in `@trigger.dev/core` isomorphic; mint path
+ all id-shape call sites swept (~40 webapp files); changeset added
(`@trigger.dev/core` patch).
- Core unit tests (encode/decode round-trip + property, generator shape,
ms sort-order incl. intra-second, parse partitioned-vs-legacy,
firekeeper round-trip): **24 pass**. `@trigger.dev/core` builds; webapp
typechecks; format/lint clean.
## Open decisions (flagged, not silently chosen)
1. **Backward-compat**: existing 27-char base62 KSUID runs now classify
LEGACY. On test cloud these are the broken/looping runs that never
completed, so this is acceptable — but worth a conscious call before
prod. No transitional length-recognition added (keeps the discriminator
clean).
2. **Storage collation**: the sort guarantee is byte-order — if the
run-ops id column is `TEXT` with default locale collation it's silently
not honored. Confirm whether `COLLATE "C"` / `BYTEA` is needed on the
run-ops schema.
3. **Region sourcing** wiring — see `regionCharForRegion` /
`REGION_CODES`.
---
## ⚠️ Required migration — deploy in lockstep
This PR renames a persisted feature-flag key/value and an env var. These
are **not** changed by the code alone and must be migrated when this
deploys, or affected orgs silently fall back to `cuid` minting (no crash
— `defaultValue: "cuid"`):
1. **Env var** (terraform): `RUN_OPS_MINT_KSUID_ENABLED` →
`RUN_OPS_MINT_ENABLED` (carry the value over).
2. **DB** `organization.featureFlags`: migrate both the key and value
together:
- key `runOpsMintKsuid` → `runOpsMintKind`
- value `"ksuid"` → `"runOpsId"`
Until an org's flag row is migrated, its `runOpsMintKind` lookup misses
and it mints `cuid` (legacy) — so no NEW-store ids for that org until
the data lands.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { describe, expect, vi } from "vitest";
|
|
|
|
// The runsRepository module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
|
// at load. Stub it (the existing runsRepository.part*.test.ts do the same) — the repo under test
|
|
// is driven entirely through injected real containers, never the stubbed module singletons.
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: {},
|
|
$replica: {},
|
|
}));
|
|
|
|
import { PostgresRunStore } from "@internal/run-store";
|
|
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
|
import { PrismaClient } from "@trigger.dev/database";
|
|
import { setTimeout } from "node:timers/promises";
|
|
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
|
import { setupClickhouseReplication } from "./utils/replicationUtils";
|
|
|
|
vi.setConfig({ testTimeout: 90_000 });
|
|
|
|
type SeedContext = {
|
|
organizationId: string;
|
|
projectId: string;
|
|
environmentId: string;
|
|
};
|
|
|
|
/**
|
|
* Creates the org/project/env parents on a single prisma client. TaskRun FKs require
|
|
* these to exist on every DB a run is hydrated from, so we seed identical parents
|
|
* (same ids) on both the legacy (PG14) and new (PG17) databases.
|
|
*/
|
|
async function seedParents(prisma: PrismaClient, slug: string): Promise<SeedContext> {
|
|
const organization = await prisma.organization.create({
|
|
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
|
});
|
|
const project = await prisma.project.create({
|
|
data: {
|
|
name: `proj-${slug}`,
|
|
slug: `proj-${slug}`,
|
|
organizationId: organization.id,
|
|
externalRef: `proj-${slug}`,
|
|
},
|
|
});
|
|
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
|
data: {
|
|
slug: `env-${slug}`,
|
|
type: "DEVELOPMENT",
|
|
projectId: project.id,
|
|
organizationId: organization.id,
|
|
apiKey: `tr_dev_${slug}`,
|
|
pkApiKey: `pk_dev_${slug}`,
|
|
shortcode: `sc-${slug}`,
|
|
},
|
|
});
|
|
|
|
return {
|
|
organizationId: organization.id,
|
|
projectId: project.id,
|
|
environmentId: runtimeEnvironment.id,
|
|
};
|
|
}
|
|
|
|
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
|
|
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
|
await prisma.organization.create({
|
|
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
|
});
|
|
await prisma.project.create({
|
|
data: {
|
|
id: ctx.projectId,
|
|
name: `proj-${slug}`,
|
|
slug: `proj-${slug}`,
|
|
organizationId: ctx.organizationId,
|
|
externalRef: `proj-${slug}`,
|
|
},
|
|
});
|
|
await prisma.runtimeEnvironment.create({
|
|
data: {
|
|
id: ctx.environmentId,
|
|
slug: `env-${slug}`,
|
|
type: "DEVELOPMENT",
|
|
projectId: ctx.projectId,
|
|
organizationId: ctx.organizationId,
|
|
apiKey: `tr_dev_${slug}_b`,
|
|
pkApiKey: `pk_dev_${slug}_b`,
|
|
shortcode: `sc-${slug}-b`,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createRun(
|
|
prisma: PrismaClient,
|
|
ctx: SeedContext,
|
|
run: {
|
|
id?: string;
|
|
friendlyId: string;
|
|
taskIdentifier?: string;
|
|
status?: any;
|
|
runTags?: string[];
|
|
createdAt?: Date;
|
|
}
|
|
) {
|
|
return prisma.taskRun.create({
|
|
data: {
|
|
...(run.id ? { id: run.id } : {}),
|
|
friendlyId: run.friendlyId,
|
|
taskIdentifier: run.taskIdentifier ?? "my-task",
|
|
status: run.status ?? "PENDING",
|
|
payload: JSON.stringify({ foo: run.friendlyId }),
|
|
traceId: run.friendlyId,
|
|
spanId: run.friendlyId,
|
|
queue: "test",
|
|
runTags: run.runTags ?? [],
|
|
runtimeEnvironmentId: ctx.environmentId,
|
|
projectId: ctx.projectId,
|
|
organizationId: ctx.organizationId,
|
|
environmentType: "DEVELOPMENT",
|
|
engine: "V2",
|
|
...(run.createdAt ? { createdAt: run.createdAt } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("RunsRepository read-through id-set hydrate (PG14 legacy + PG17 new)", () => {
|
|
// --- DoD line + e2e #6: split fan-out across new + legacy-replica with known-migrated filter ---
|
|
replicationContainerTest(
|
|
"split mode hydrates the CH id-set as the union of NEW + legacy-replica rows, byte-identical and id-desc ordered",
|
|
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
|
// The fixture's PG14 container is the LEGACY read replica AND the replication source that
|
|
// feeds the ClickHouse id-set. The dedicated PG17 container is the NEW run-ops DB.
|
|
const { clickhouse } = await setupClickhouseReplication({
|
|
prisma,
|
|
databaseUrl: postgresContainer.getConnectionUri(),
|
|
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
|
redisOptions,
|
|
});
|
|
|
|
const { url: newUrl } = await createPostgresContainer(network, {
|
|
imageTag: "docker.io/postgres:17",
|
|
});
|
|
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
|
|
|
try {
|
|
const ctx = await seedParents(prisma, "split1");
|
|
await mirrorParents(prismaNew, ctx, "split1");
|
|
|
|
// Seed all four runs on PG14 (legacy + replication source -> CH gets the full id-set).
|
|
const legacyOnlyA = await createRun(prisma, ctx, { friendlyId: "run_legacyA" });
|
|
const legacyOnlyB = await createRun(prisma, ctx, { friendlyId: "run_legacyB" });
|
|
const migratedA = await createRun(prisma, ctx, { friendlyId: "run_newA" });
|
|
const migratedB = await createRun(prisma, ctx, { friendlyId: "run_newB" });
|
|
|
|
// The two "migrated" runs ALSO live on the NEW DB (authoritative during retention).
|
|
// Same ids so set-membership and ordering line up with the CH id-set.
|
|
await createRun(prismaNew, { ...ctx }, { friendlyId: "run_newA" });
|
|
await createRun(prismaNew, { ...ctx }, { friendlyId: "run_newB" });
|
|
// Force the NEW rows to share the legacy ids exactly.
|
|
await prismaNew.taskRun.update({
|
|
where: { friendlyId: "run_newA" },
|
|
data: { id: migratedA.id },
|
|
});
|
|
await prismaNew.taskRun.update({
|
|
where: { friendlyId: "run_newB" },
|
|
data: { id: migratedB.id },
|
|
});
|
|
|
|
await setTimeout(1500);
|
|
|
|
const runsRepository = new RunsRepository({
|
|
prisma, // single-DB default handle (unused on the split path here)
|
|
clickhouse,
|
|
runStore: new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }),
|
|
readThrough: {
|
|
splitEnabled: true,
|
|
newClient: prismaNew,
|
|
legacyReplica: prisma,
|
|
},
|
|
});
|
|
|
|
const { runs } = await runsRepository.listRuns({
|
|
page: { size: 10 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
|
|
// Union of all four, id-desc ordered.
|
|
const expectedIds = [migratedA.id, migratedB.id, legacyOnlyA.id, legacyOnlyB.id].sort(
|
|
(a, b) => (a < b ? 1 : a > b ? -1 : 0)
|
|
);
|
|
expect(runs.map((r) => r.id)).toEqual(expectedIds);
|
|
|
|
// Byte-identity for a NEW-served row (from PG17) and a legacy-served row (from PG14).
|
|
const newRow = runs.find((r) => r.id === migratedA.id)!;
|
|
expect(newRow.friendlyId).toBe("run_newA");
|
|
expect(newRow.taskIdentifier).toBe("my-task");
|
|
const legacyRow = runs.find((r) => r.id === legacyOnlyA.id)!;
|
|
expect(legacyRow.friendlyId).toBe("run_legacyA");
|
|
|
|
// Order parity with single-DB: a pure id-desc sort of the same ids.
|
|
expect(runs.map((r) => r.id)).toEqual(
|
|
[...runs.map((r) => r.id)].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
|
|
);
|
|
} finally {
|
|
await prismaNew.$disconnect();
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Passthrough (single-DB): one plain store read, legacy never touched ---
|
|
replicationContainerTest(
|
|
"single-DB passthrough hydrates from one store read and never touches the legacy boundary",
|
|
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
|
const { clickhouse } = await setupClickhouseReplication({
|
|
prisma,
|
|
databaseUrl: postgresContainer.getConnectionUri(),
|
|
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
|
redisOptions,
|
|
});
|
|
|
|
const ctx = await seedParents(prisma, "passthrough");
|
|
const run = await createRun(prisma, ctx, { friendlyId: "run_passthrough" });
|
|
|
|
await setTimeout(1500);
|
|
|
|
// splitEnabled false → the split branch is never entered (one plain store read).
|
|
const runsRepository = new RunsRepository({
|
|
prisma,
|
|
clickhouse,
|
|
readThrough: {
|
|
splitEnabled: false,
|
|
legacyReplica: prisma,
|
|
},
|
|
});
|
|
|
|
const { runs } = await runsRepository.listRuns({
|
|
page: { size: 10 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0].id).toBe(run.id);
|
|
|
|
const friendlyIds = await runsRepository.listFriendlyRunIds({
|
|
page: { size: 10 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
expect(friendlyIds).toEqual(["run_passthrough"]);
|
|
}
|
|
);
|
|
|
|
// --- Ordering: the hydrated page follows the ClickHouse keyset (created_at desc), NOT raw id ---
|
|
replicationContainerTest(
|
|
"listRuns orders by the ClickHouse created_at keyset, not by raw id",
|
|
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
|
const { clickhouse } = await setupClickhouseReplication({
|
|
prisma,
|
|
databaseUrl: postgresContainer.getConnectionUri(),
|
|
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
|
redisOptions,
|
|
});
|
|
|
|
const ctx = await seedParents(prisma, "ordering");
|
|
// Make chronological order the OPPOSITE of id order: the run created FIRST (smaller
|
|
// time-prefixed cuid id) is given the MOST-RECENT created_at. A correct list returns
|
|
// [mostRecent, oldest] (created_at desc); the old id-desc hydrate would invert it.
|
|
// created_at is set at insert time (not via update) so ClickHouse never holds a second
|
|
// ReplacingMergeTree version that could surface as a duplicate.
|
|
const now = Date.now();
|
|
const mostRecent = await createRun(prisma, ctx, {
|
|
friendlyId: "run_orderA",
|
|
createdAt: new Date(now),
|
|
});
|
|
const oldest = await createRun(prisma, ctx, {
|
|
friendlyId: "run_orderB",
|
|
createdAt: new Date(now - 3_600_000),
|
|
});
|
|
expect(mostRecent.id < oldest.id).toBe(true); // raw id-desc would yield [oldest, mostRecent]
|
|
|
|
await setTimeout(1500);
|
|
|
|
const runsRepository = new RunsRepository({ prisma, clickhouse });
|
|
const { runs } = await runsRepository.listRuns({
|
|
page: { size: 10 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
|
|
expect(runs.map((r) => r.id)).toEqual([mostRecent.id, oldest.id]);
|
|
}
|
|
);
|
|
|
|
// --- listFriendlyRunIds parity: split union, id projected away to a plain string[] ---
|
|
replicationContainerTest(
|
|
"listFriendlyRunIds returns the union of friendly ids across new + legacy, projecting id away",
|
|
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
|
const { clickhouse } = await setupClickhouseReplication({
|
|
prisma,
|
|
databaseUrl: postgresContainer.getConnectionUri(),
|
|
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
|
redisOptions,
|
|
});
|
|
|
|
const { url: newUrl } = await createPostgresContainer(network, {
|
|
imageTag: "docker.io/postgres:17",
|
|
});
|
|
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
|
|
|
try {
|
|
const ctx = await seedParents(prisma, "friendly");
|
|
await mirrorParents(prismaNew, ctx, "friendly");
|
|
|
|
const legacy = await createRun(prisma, ctx, { friendlyId: "run_fLegacy" });
|
|
const migrated = await createRun(prisma, ctx, { friendlyId: "run_fNew" });
|
|
await createRun(prismaNew, ctx, { friendlyId: "run_fNew" });
|
|
await prismaNew.taskRun.update({
|
|
where: { friendlyId: "run_fNew" },
|
|
data: { id: migrated.id },
|
|
});
|
|
|
|
await setTimeout(1500);
|
|
|
|
const runsRepository = new RunsRepository({
|
|
prisma,
|
|
clickhouse,
|
|
runStore: new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew }),
|
|
readThrough: {
|
|
splitEnabled: true,
|
|
newClient: prismaNew,
|
|
legacyReplica: prisma,
|
|
},
|
|
});
|
|
|
|
const friendlyIds = await runsRepository.listFriendlyRunIds({
|
|
page: { size: 10 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
|
|
expect(friendlyIds.every((f) => typeof f === "string")).toBe(true);
|
|
expect([...friendlyIds].sort()).toEqual(["run_fLegacy", "run_fNew"]);
|
|
// id projected away: a friendlyId is never a run internal id.
|
|
expect(friendlyIds).not.toContain(legacy.id);
|
|
} finally {
|
|
await prismaNew.$disconnect();
|
|
}
|
|
}
|
|
);
|
|
|
|
// Full-keyset walk over interleaved cuid + run-ops ids: hydration must preserve the ClickHouse
|
|
// (created_at DESC, run_id DESC) order across the id-space seam. A hydrate that reverts to lexical
|
|
// `id desc` splits the two id-spaces into separate blocks, so it would fail this walk.
|
|
replicationContainerTest(
|
|
"paginating the full keyset enumerates every interleaved cuid/run-ops id once, in CH keyset order, with no empty page",
|
|
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
|
const { clickhouse } = await setupClickhouseReplication({
|
|
prisma,
|
|
databaseUrl: postgresContainer.getConnectionUri(),
|
|
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
|
redisOptions,
|
|
});
|
|
|
|
const ctx = await seedParents(prisma, "keysetwalk");
|
|
|
|
// cuid-shaped ids (25 chars, "c" prefix) and v1-shaped ids (26 chars, "2" prefix, version "1"). Lexical
|
|
// `id desc` groups all "c" ids ahead of all "2" ids; the created_at order below interleaves
|
|
// them, so the two orders genuinely differ across the seam.
|
|
const cuid = (n: number) => `c${String(n).padStart(24, "0")}`;
|
|
const runOpsId = (n: number) => `2${String(n).padStart(23, "0")}01`;
|
|
|
|
// created_at DESC order (index 0 = most recent) interleaves the id-spaces: run-ops id, cuid,
|
|
// run-ops id, cuid, run-ops id, cuid.
|
|
const now = Date.now();
|
|
const seeds = [
|
|
{ id: runOpsId(6), friendlyId: "run_k6", createdAt: new Date(now - 0 * 60_000) },
|
|
{ id: cuid(5), friendlyId: "run_c5", createdAt: new Date(now - 1 * 60_000) },
|
|
{ id: runOpsId(4), friendlyId: "run_k4", createdAt: new Date(now - 2 * 60_000) },
|
|
{ id: cuid(3), friendlyId: "run_c3", createdAt: new Date(now - 3 * 60_000) },
|
|
{ id: runOpsId(2), friendlyId: "run_k2", createdAt: new Date(now - 4 * 60_000) },
|
|
{ id: cuid(1), friendlyId: "run_c1", createdAt: new Date(now - 5 * 60_000) },
|
|
];
|
|
for (const s of seeds) {
|
|
await createRun(prisma, ctx, s);
|
|
}
|
|
|
|
await setTimeout(1500);
|
|
|
|
const runsRepository = new RunsRepository({ prisma, clickhouse });
|
|
|
|
// The authoritative order the hydrate must reproduce: exactly the CH keyset the id-list scan
|
|
// returns (created_at DESC, run_id DESC). Lexical id-desc of the same ids differs from this.
|
|
const chOrder = await runsRepository.listRunIds({
|
|
page: { size: 100 },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
const expectedOrder = chOrder.runIds;
|
|
const lexicalIdDesc = [...expectedOrder].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
expect(expectedOrder).not.toEqual(lexicalIdDesc); // the seam actually separates the two orders
|
|
|
|
// Walk the whole keyset a page at a time.
|
|
const walked: string[] = [];
|
|
let cursor: string | undefined;
|
|
let pages = 0;
|
|
while (true) {
|
|
const { runs, pagination } = await runsRepository.listRuns({
|
|
page: { size: 2, cursor },
|
|
projectId: ctx.projectId,
|
|
environmentId: ctx.environmentId,
|
|
organizationId: ctx.organizationId,
|
|
});
|
|
pages++;
|
|
expect(pages).toBeLessThan(20); // guard against a non-terminating walk
|
|
|
|
for (const r of runs) walked.push(r.id);
|
|
|
|
if (!pagination.nextCursor) break;
|
|
// No empty page may be returned while more pages exist.
|
|
expect(runs.length).toBeGreaterThan(0);
|
|
cursor = pagination.nextCursor;
|
|
}
|
|
|
|
// Every seeded id enumerated exactly once.
|
|
expect(walked.slice().sort()).toEqual(seeds.map((s) => s.id).sort());
|
|
expect(new Set(walked).size).toBe(seeds.length);
|
|
// The emitted order equals the CH keyset order across the id-space seam.
|
|
expect(walked).toEqual(expectedOrder);
|
|
}
|
|
);
|
|
});
|