Skip to content

Commit 48a6686

Browse files
d-csclaude
andcommitted
fix(run-ops): DNS-safe, sortable base32hex run id (replace base62 KSUID)
The run-ops split minted NEW-store run ids as 27-char base62 KSUIDs. The supervisor puts the run id in the k8s pod name (runner-<id>), and pod names must be DNS-1123 labels (lowercase [a-z0-9-]), so uppercase base62 ids made k8s reject the pod (422) and those runs never launched. .toLowerCase() can't fix it (base62 folds distinct symbols, colliding ids and destroying sort). Change the encoding, not the structure: mint a 26-char lowercase base32hex id (24-char core = 6-byte ms timestamp + 9 CSPRNG bytes, + region char + version char "1"). Lexicographically time-sortable at ms resolution, DNS-safe from birth, hyphen-free (so firekeeper's runner-<id>-attempt-N round-trip is unchanged). The residency discriminator switches from id length to the fixed version char, so the format can evolve without a hard migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 119189f commit 48a6686

66 files changed

Lines changed: 662 additions & 524 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/olive-bees-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Change the run-ops run id format from a 27-char base62 KSUID to a 26-char lowercase base32hex id (ms timestamp + CSPRNG core, region char, version char) so run ids are DNS-1123 safe for Kubernetes pod names while staying lexicographically time-sortable

apps/webapp/app/runEngine/concerns/idempotencyResidency.server.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ function makeDeps(over: Partial<ResolveIdempotencyClientDeps>): ResolveIdempoten
1818
legacyClient: LEGACY_CLIENT,
1919
resolveMintKind: async () => "ksuid",
2020
classify: (id) => {
21-
if (id.length === 27) return "NEW";
21+
if (id.length === 26 && id[25] === "1") return "NEW";
2222
if (id.length === 25) return "LEGACY";
2323
throw new Error(`unclassifiable: ${id.length}`);
2424
},
@@ -55,7 +55,7 @@ describe("resolveIdempotencyDedupClient", () => {
5555
});
5656

5757
it("routes a child to the NEW client when the ksuid parent is NEW-resident", async () => {
58-
const ksuidParent = RunId.toFriendlyId("a".repeat(27));
58+
const ksuidParent = RunId.toFriendlyId("a".repeat(24) + "01");
5959
const client = await resolveIdempotencyDedupClient(
6060
{ environmentForMint: env, parentRunFriendlyId: ksuidParent },
6161
makeDeps({ resolveMintKind: async () => "cuid" }) // mint flag must NOT win for a child

apps/webapp/app/runEngine/services/triggerFailedTask.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RunEngine } from "@internal/run-engine";
22
import { TaskRunErrorCodes, type TaskRunError } from "@trigger.dev/core/v3";
3-
import { RunId, generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
3+
import { RunId, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
44
import type {
55
PrismaClientOrTransaction,
66
RuntimeEnvironmentType,
@@ -103,7 +103,7 @@ export class TriggerFailedTaskService {
103103
});
104104

105105
return mintKind === "ksuid"
106-
? RunId.toFriendlyId(generateKsuidId())
106+
? RunId.toFriendlyId(generateRunOpsId())
107107
: RunId.generate().friendlyId;
108108
}
109109

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
TriggerTraceContext,
1515
} from "@trigger.dev/core/v3";
1616
import {
17-
generateKsuidId,
17+
generateRunOpsId,
1818
parseTraceparent,
1919
RunId,
2020
serializeTraceparent,
@@ -136,9 +136,14 @@ export class RunEngineTriggerTaskService {
136136
// - CHILD run (has a parent): inherit the parent's residency by id-shape, so a
137137
// parent and child never split across stores (ksuid parent → ksuid child,
138138
// cuid parent → cuid child).
139+
// `region` is the caller-requested region (body.options.region). The id is
140+
// minted before the worker queue is resolved (the idempotency concern needs
141+
// the friendlyId first), so the stamped region char reflects the requested
142+
// region — or the default char when the run targets the default region.
139143
private async mintRunFriendlyId(
140144
environment: AuthenticatedEnvironment,
141-
parentRunFriendlyId?: string
145+
parentRunFriendlyId?: string,
146+
region?: string
142147
): Promise<string> {
143148
const mintKind = parentRunFriendlyId
144149
? resolveInheritedMintKind(parentRunFriendlyId)
@@ -149,7 +154,7 @@ export class RunEngineTriggerTaskService {
149154
});
150155

151156
return mintKind === "ksuid"
152-
? RunId.toFriendlyId(generateKsuidId())
157+
? RunId.toFriendlyId(generateRunOpsId(region))
153158
: RunId.generate().friendlyId;
154159
}
155160

@@ -183,7 +188,11 @@ export class RunEngineTriggerTaskService {
183188
// parent is present, else the environment's setting.
184189
const runFriendlyId =
185190
options?.runFriendlyId ??
186-
(await this.mintRunFriendlyId(environment, body.options?.parentRunId));
191+
(await this.mintRunFriendlyId(
192+
environment,
193+
body.options?.parentRunId,
194+
body.options?.region
195+
));
187196
const triggerRequest = {
188197
taskId,
189198
friendlyId: runFriendlyId,

apps/webapp/app/utils/friendlyId.test.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,31 @@ import { describe, expect, it } from "vitest";
22
import {
33
BatchId,
44
generateFriendlyId,
5-
generateKsuidId,
5+
generateRunOpsId,
66
RunId,
77
} from "@trigger.dev/core/v3/isomorphic";
88
import { isValidFriendlyId, makeFriendlyIdValidator } from "./friendlyId";
99

1010
describe("isValidFriendlyId", () => {
1111
it("accepts every id generation the real generators produce", () => {
12-
// nanoid (legacy V1), cuid (run-engine), ksuid (run-ops split)
12+
// nanoid (legacy V1), cuid (run-engine), run-ops v1 (run-ops split)
1313
expect(isValidFriendlyId(generateFriendlyId("run"), "run")).toBe(true);
1414
expect(isValidFriendlyId(RunId.generate().friendlyId, "run")).toBe(true);
15-
expect(isValidFriendlyId(RunId.toFriendlyId(generateKsuidId()), "run")).toBe(true);
15+
expect(isValidFriendlyId(RunId.toFriendlyId(generateRunOpsId()), "run")).toBe(true);
1616

1717
expect(isValidFriendlyId(generateFriendlyId("batch"), "batch")).toBe(true);
1818
expect(isValidFriendlyId(BatchId.generate().friendlyId, "batch")).toBe(true);
19-
expect(isValidFriendlyId(BatchId.toFriendlyId(generateKsuidId()), "batch")).toBe(true);
19+
expect(isValidFriendlyId(BatchId.toFriendlyId(generateRunOpsId()), "batch")).toBe(true);
2020
});
2121

22-
it("accepts each valid body length (21 nanoid, 25 cuid, 27 ksuid)", () => {
22+
it("accepts each valid body length (21 nanoid, 25 cuid, 26 run-ops v1, 27 legacy base62)", () => {
2323
expect(isValidFriendlyId("run_" + "a".repeat(21), "run")).toBe(true);
2424
expect(isValidFriendlyId("run_" + "a".repeat(25), "run")).toBe(true);
25+
expect(isValidFriendlyId("run_" + "a".repeat(26), "run")).toBe(true);
2526
expect(isValidFriendlyId("run_" + "a".repeat(27), "run")).toBe(true);
2627
});
2728

28-
it("accepts mixed-case (uppercase) ksuid bodies", () => {
29+
it("accepts mixed-case (uppercase) legacy base62 bodies", () => {
2930
expect(isValidFriendlyId("run_2ABCdefGHI0123456789jklMN", "run")).toBe(true);
3031
});
3132

@@ -39,7 +40,7 @@ describe("isValidFriendlyId", () => {
3940
});
4041

4142
it("rejects body lengths that match no generator", () => {
42-
for (const len of [0, 20, 22, 24, 26, 28]) {
43+
for (const len of [0, 20, 22, 24, 28]) {
4344
expect(isValidFriendlyId("run_" + "a".repeat(len), "run")).toBe(false);
4445
}
4546
});
@@ -64,8 +65,8 @@ describe("makeFriendlyIdValidator", () => {
6465
it("returns undefined for a valid id of any generation", () => {
6566
expect(validateRunId(generateFriendlyId("run"))).toBeUndefined();
6667
expect(validateRunId(RunId.generate().friendlyId)).toBeUndefined();
67-
expect(validateRunId(RunId.toFriendlyId(generateKsuidId()))).toBeUndefined();
68-
expect(validateBatchId(BatchId.toFriendlyId(generateKsuidId()))).toBeUndefined();
68+
expect(validateRunId(RunId.toFriendlyId(generateRunOpsId()))).toBeUndefined();
69+
expect(validateBatchId(BatchId.toFriendlyId(generateRunOpsId()))).toBeUndefined();
6970
});
7071

7172
it("reports a wrong prefix distinctly from a wrong shape", () => {

apps/webapp/app/utils/friendlyId.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
import { CUID_LENGTH, KSUID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
1+
import { CUID_LENGTH, RUN_OPS_ID_LENGTH } from "@trigger.dev/core/v3/isomorphic";
22

3-
// The body after `<prefix>_` is a base62 id; three generator lengths remain
4-
// valid in existing data and must all be accepted: 21 (nanoid), 25 (cuid),
5-
// 27 (ksuid). cuid/ksuid come from core so this tracks any future change.
3+
// The body after `<prefix>_` is an alphanumeric id; four generator lengths
4+
// remain valid in existing data and must all be accepted: 21 (nanoid),
5+
// 25 (cuid), 26 (run-ops v1 base32hex), 27 (pre-cutover base62, kept so old
6+
// ids still pass filter validation). cuid/run-ops come from core so this
7+
// tracks any future change.
68
const NANOID_BODY_LENGTH = 21;
9+
const LEGACY_BASE62_BODY_LENGTH = 27;
710
const VALID_BODY_LENGTHS: ReadonlySet<number> = new Set([
811
NANOID_BODY_LENGTH,
912
CUID_LENGTH,
10-
KSUID_LENGTH,
13+
RUN_OPS_ID_LENGTH,
14+
LEGACY_BASE62_BODY_LENGTH,
1115
]);
1216

13-
const BASE62 = /^[0-9A-Za-z]+$/;
17+
const ALPHANUMERIC = /^[0-9A-Za-z]+$/;
1418

1519
export function isValidFriendlyId(value: string, prefix: string): boolean {
1620
const marker = `${prefix}_`;
1721
if (!value.startsWith(marker)) return false;
1822
const body = value.slice(marker.length);
19-
return VALID_BODY_LENGTHS.has(body.length) && BASE62.test(body);
23+
return VALID_BODY_LENGTHS.has(body.length) && ALPHANUMERIC.test(body);
2024
}
2125

2226
export function makeFriendlyIdValidator(prefix: string, label: string) {

apps/webapp/app/v3/runOpsMigration/crossSeamGuard.server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export function selectStoreForWaitpoint(
5656

5757
const classify = deps?.classify ?? ownerEngine;
5858

59-
// Loud on ambiguity: classify throws UnclassifiableRunId with the real id; never catch-and-default.
6059
const residency: RunOpsResidency = classify(input.waitpointId);
6160

6261
const pinnedReason = applyPinningRules(input);

apps/webapp/app/v3/runOpsMigration/mintBatchFriendlyId.server.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { batchIdForMintKind, resolveBatchMintKind } from "./mintBatchFriendlyId.
33
import { classifyKind } from "@trigger.dev/core/v3/isomorphic";
44

55
describe("batchIdForMintKind (pure)", () => {
6-
it("ksuid -> 27-char classifiable NEW batch id (no 21-char ids)", () => {
6+
it("ksuid -> 26-char classifiable NEW batch id (no 21-char ids)", () => {
77
const r = batchIdForMintKind("ksuid");
88
expect(r.friendlyId.startsWith("batch_")).toBe(true);
9-
expect(r.id.length).toBe(27);
9+
expect(r.id.length).toBe(26);
1010
expect(classifyKind(r.id)).toBe("ksuid");
1111
expect(classifyKind(r.friendlyId)).toBe("ksuid");
1212
});
@@ -20,7 +20,7 @@ describe("batchIdForMintKind (pure)", () => {
2020

2121
it("never mints a 21-char id", () => {
2222
for (const kind of ["cuid", "ksuid"] as const) {
23-
expect([25, 27]).toContain(batchIdForMintKind(kind).id.length);
23+
expect([25, 26]).toContain(batchIdForMintKind(kind).id.length);
2424
}
2525
});
2626
});
@@ -52,7 +52,7 @@ describe("resolveBatchMintKind", () => {
5252
});
5353

5454
it("CHILD batch inherits a ksuid (NEW) parent by id-shape", async () => {
55-
const parentRunFriendlyId = `run_${"a".repeat(27)}`;
55+
const parentRunFriendlyId = `run_${"a".repeat(24) + "01"}`;
5656
const resolveRunIdMintKind = vi.fn();
5757

5858
const kind = await resolveBatchMintKind({
@@ -94,7 +94,7 @@ describe("resolveBatchMintKind", () => {
9494
});
9595

9696
it("FLIP ksuid->cuid: a ksuid (NEW) parent still mints a ksuid child though the flag now says cuid", async () => {
97-
const parentRunFriendlyId = `run_${"a".repeat(27)}`;
97+
const parentRunFriendlyId = `run_${"a".repeat(24) + "01"}`;
9898
const resolveRunIdMintKind = vi.fn().mockResolvedValue("cuid"); // flag flipped back to cuid
9999
const kind = await resolveBatchMintKind({
100100
environment,

apps/webapp/app/v3/runOpsMigration/mintBatchFriendlyId.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BatchId, generateKsuidId } from "@trigger.dev/core/v3/isomorphic";
1+
import { BatchId, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
22
import {
33
resolveRunIdMintKind as defaultResolveRunIdMintKind,
44
type RunIdMintKind,
@@ -15,7 +15,7 @@ const defaultDeps: ResolveDeps = {
1515

1616
export function batchIdForMintKind(kind: RunIdMintKind): { id: string; friendlyId: string } {
1717
if (kind === "ksuid") {
18-
const id = generateKsuidId();
18+
const id = generateRunOpsId();
1919
return { id, friendlyId: BatchId.toFriendlyId(id) };
2020
}
2121
return BatchId.generate();

apps/webapp/app/v3/runOpsMigration/readThrough.server.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { readThroughRun, type ReadThroughResult } from "./readThrough.server";
1010

1111
vi.setConfig({ testTimeout: 60_000 });
1212

13-
// 25-char cuid body → LEGACY residency. 27-char body → NEW residency.
13+
// 25-char cuid body → LEGACY residency. 26-char v1 body (version "1" at index 25) → NEW residency.
1414
const LEGACY_RUN_ID = "run_" + "a".repeat(25);
15-
const NEW_RUN_ID = "run_" + "b".repeat(27);
15+
const NEW_RUN_ID = "run_" + "b".repeat(24) + "01";
1616

1717
// Lightweight real read: a trivial `$queryRaw` that genuinely hits the given container.
1818
// `hit` controls whether the read "finds" the run, so we exercise routing without

0 commit comments

Comments
 (0)