Skip to content

Commit 092b9ef

Browse files
d-csclaude
andauthored
fix(run-ops): DNS-safe, sortable base32hex run id (replace base62 KSUID) (#4154)
## 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>
1 parent d977691 commit 092b9ef

104 files changed

Lines changed: 1195 additions & 992 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.

.github/workflows/e2e.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ jobs:
4242
- name: 📥 Download deps
4343
run: pnpm install --frozen-lockfile --filter trigger.dev...
4444

45+
# Prisma clients generate concurrently and share one engine binary in the
46+
# pnpm store; the generate script retries the transient Windows EPERM race.
4547
- name: 📀 Generate Prisma Client
4648
run: pnpm run generate
4749

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,10 +1727,10 @@ const EnvironmentSchema = z
17271727
RUN_REPLICATION_LEGACY_ORIGIN_GENERATION: z.coerce.number().int().default(0),
17281728
RUN_REPLICATION_NEW_ORIGIN_GENERATION: z.coerce.number().int().default(1),
17291729

1730-
// Run-ops KSUID mint cutover — per-env, canary-first, OFF by default.
1731-
// Even when on, an env mints KSUID only if its per-org runOpsMintKsuid flag is
1732-
// "ksuid" AND isSplitEnabled() is true. Cache mirrors REALTIME_BACKEND_FLAG_CACHE_*.
1733-
RUN_OPS_MINT_KSUID_ENABLED: BoolEnv.default(false),
1730+
// Run-ops id mint cutover — per-env, canary-first, OFF by default.
1731+
// Even when on, an env mints run-ops ids only if its per-org runOpsMintKind flag is
1732+
// "runOpsId" AND isSplitEnabled() is true. Cache mirrors REALTIME_BACKEND_FLAG_CACHE_*.
1733+
RUN_OPS_MINT_ENABLED: BoolEnv.default(false),
17341734
RUN_OPS_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
17351735
RUN_OPS_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000),
17361736

apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
131131
}
132132

133133
// Split: resolve the batch row new-first then off the legacy READ REPLICA only (a batch id may
134-
// be cuid or ksuid, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
134+
// be cuid or run-ops id, and a cuid-shaped id can still have been backfilled onto NEW, so id-shape
135135
// residency is not authoritative for the row — the new-first-then-legacy probe is), then
136136
// hydrate every member run independently via the per-run read-through primitive.
137137
async #callSplit(

apps/webapp/app/presenters/v3/ApiWaitpointPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class ApiWaitpointPresenter extends BasePresenter {
4242
// Public waitpoint retrieve. Split on: new run-ops client first, then the LEGACY
4343
// RUN-OPS READ REPLICA ONLY on a new-probe miss — never the legacy primary.
4444
// Split off (single-DB / self-host): one plain waitpoint.findFirst against the replica
45-
// (passthrough). The waitpointId is the residency-classifiable KSUID id (the route
45+
// (passthrough). The waitpointId is the residency-classifiable run-ops id (the route
4646
// pre-decodes the friendlyId via WaitpointId.toId).
4747
const hydrate = (client: PrismaReplicaClient) =>
4848
client.waitpoint.findFirst({

apps/webapp/app/presenters/v3/BatchListPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class BatchListPresenter extends BasePresenter {
101101
}
102102
}
103103

104-
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or ksuid).
104+
// codepoint comparator (NEVER localeCompare): BatchTaskRun.id is ASCII (cuid or run-ops id).
105105
const sign = direction === "forward" ? 1 : -1; // forward => DESC; backward => ASC
106106
return Array.from(byId.values())
107107
.sort((a, b) => (a.id < b.id ? sign : a.id > b.id ? -sign : 0))

apps/webapp/app/presenters/v3/BatchPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export class BatchPresenter extends BasePresenter {
7979
const batchResult = await readThrough<BatchRow>({
8080
// The read-through key; here it is the batch friendlyId. A cuid-shaped batch friendlyId
8181
// classifies as LEGACY and the read-through probes both stores (new first, then legacy
82-
// replica); a ksuid-shaped one (cut-over orgs) classifies as NEW and reads only the new
82+
// replica); a run-ops-shaped one (cut-over orgs) classifies as NEW and reads only the new
8383
// store — either way the row is found on the DB that owns it.
8484
runId: batchId,
8585
environmentId,

apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const { action, loader } = createActionApiRoute(
7878
}
7979
}
8080

81-
// Create the waitpoint. Co-locate it with the owning run (run-ops split) so a ksuid
81+
// Create the waitpoint. Co-locate it with the owning run (run-ops split) so a run-ops id
8282
// run's input-stream waitpoint lands on the run's DB and its block edge resolves.
8383
const result = await engine.createManualWaitpoint({
8484
runId: run.id,

apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const { action, loader } = createActionApiRoute(
9999
}
100100
}
101101

102-
// Create the waitpoint. Co-locate it with the owning run (run-ops split) so a ksuid
102+
// Create the waitpoint. Co-locate it with the owning run (run-ops split) so a run-ops id
103103
// run's session-stream waitpoint lands on the run's DB and its block edge resolves.
104104
const result = await engine.createManualWaitpoint({
105105
runId: run.id,

apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const { action } = createActionApiRoute(
4242
: undefined;
4343

4444
const { waitpoint } = await engine.createDateTimeWaitpoint({
45-
// Co-locate the waitpoint with the run that blocks on it (run-ops split): a ksuid run lives
45+
// Co-locate the waitpoint with the run that blocks on it (run-ops split): a run-ops run lives
4646
// on the dedicated DB, but the minted waitpoint id is always a cuid, so without the run id
4747
// the waitpoint would route to the control-plane DB and the block edge would never resolve.
4848
runId: run.id,

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ function makeDeps(over: Partial<ResolveIdempotencyClientDeps>): ResolveIdempoten
1616
fallbackClient: FALLBACK,
1717
newClient: NEW_CLIENT,
1818
legacyClient: LEGACY_CLIENT,
19-
resolveMintKind: async () => "ksuid",
19+
resolveMintKind: async () => "runOpsId",
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
},
@@ -38,10 +38,10 @@ describe("resolveIdempotencyDedupClient", () => {
3838
expect(client).toBe(FALLBACK);
3939
});
4040

41-
it("routes a root run to the NEW client when the env mints ksuid", async () => {
41+
it("routes a root run to the NEW client when the env mints run-ops ids", async () => {
4242
const client = await resolveIdempotencyDedupClient(
4343
{ environmentForMint: env, parentRunFriendlyId: undefined },
44-
makeDeps({ resolveMintKind: async () => "ksuid" })
44+
makeDeps({ resolveMintKind: async () => "runOpsId" })
4545
);
4646
expect(client).toBe(NEW_CLIENT);
4747
});
@@ -54,10 +54,10 @@ describe("resolveIdempotencyDedupClient", () => {
5454
expect(client).toBe(LEGACY_CLIENT);
5555
});
5656

57-
it("routes a child to the NEW client when the ksuid parent is NEW-resident", async () => {
58-
const ksuidParent = RunId.toFriendlyId("a".repeat(27));
57+
it("routes a child to the NEW client when the run-ops parent is NEW-resident", async () => {
58+
const runOpsParent = RunId.toFriendlyId("a".repeat(24) + "01");
5959
const client = await resolveIdempotencyDedupClient(
60-
{ environmentForMint: env, parentRunFriendlyId: ksuidParent },
60+
{ environmentForMint: env, parentRunFriendlyId: runOpsParent },
6161
makeDeps({ resolveMintKind: async () => "cuid" }) // mint flag must NOT win for a child
6262
);
6363
expect(client).toBe(NEW_CLIENT);
@@ -67,7 +67,7 @@ describe("resolveIdempotencyDedupClient", () => {
6767
const cuidParent = RunId.toFriendlyId("b".repeat(25));
6868
const client = await resolveIdempotencyDedupClient(
6969
{ environmentForMint: env, parentRunFriendlyId: cuidParent },
70-
makeDeps({ resolveMintKind: async () => "ksuid" }) // mint flag must NOT win for a child
70+
makeDeps({ resolveMintKind: async () => "runOpsId" }) // mint flag must NOT win for a child
7171
);
7272
expect(client).toBe(LEGACY_CLIENT);
7373
});

0 commit comments

Comments
 (0)