Skip to content

Commit d59743b

Browse files
authored
fix(webapp,run-ops-database): keep run-ops batch items co-resident with their batch (#4178)
## Summary Three fixes to the run-ops database split (the Cloud-only mode where run-lifecycle rows live on a dedicated Postgres). All are inert in the default single-database deployment. The main fix: on the batch trigger paths, a parentless batch's item runs chose their physical store from a fresh per-org mint-flag read at processing time, so flipping an org's flag mid-batch could land an item in a different store than its batch, breaking the `TaskRun.batchId` foreign key (or silently orphaning the item). The other two harden the split's safety nets: the schema-parity test now actually compares columns, and the read fan-out gate now signals when it has been silently disabled. ## Batch item residency `RunEngineBatchTriggerService` (api.v2) and the BatchQueue item callback (api.v3) now anchor each item's id mint on the batch's own friendlyId, mirroring the already-safe `BatchTriggerV3Service`. Residency is a pure id-shape check, so an item can no longer diverge from its batch across a mid-batch flag flip. The pre-failed-run fallback is anchored the same way (it also sets `batchId`), and the shared mint branch is consolidated into one helper so every mint path stays in lockstep. No new database queries; single-database mode is unchanged (a cuid-shaped batch friendlyId yields a cuid item). ## Schema parity test The parity test previously read only the dedicated schema and matched model headers with regexes, so it never compared columns and could not catch a run-subgraph column that diverged between the two physical schemas. It now parses both schemas and asserts bidirectional scalar-column parity (type, nullability, array-ness, default) across the run-subgraph models, and fails on any field line it can't parse. Scoped to the run-subgraph models so unrelated control-plane edits don't break it. ## Read fan-out signal The split read fan-out gate is decided by the object identity of the NEW vs control-plane clients. It now warns when both run-ops URLs are set but the NEW client isn't a distinct instance (fan-out silently off), and a new test exercises the real topology-into-gate wiring so a future refactor that aliases the clients can't disable fan-out unnoticed. ## Verification New unit and glue tests cover all three changes; the DB-backed residency, store-routing, and topology suites pass against real Postgres; `typecheck` is clean for both packages.
1 parent add0a7d commit d59743b

16 files changed

Lines changed: 959 additions & 57 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Anchor batch item run-ops residency on the batch's own friendlyId (not a fresh per-org flag read) in the run-engine batch trigger service and the BatchQueue item callback, on both the success path and the pre-failed-run failure path, so a mid-batch mint-flag flip can no longer mint an item (or a pre-failed item) into a different physical store than its BatchTaskRun row.

apps/webapp/app/db.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
290290
controlPlaneReplica: $replica,
291291
hasNewUrl: !!env.RUN_OPS_DATABASE_URL,
292292
hasLegacyUrl: !!env.RUN_OPS_LEGACY_DATABASE_URL,
293+
logger,
293294
});
294295

295296
// Boot-time interlock: if the flag is on but the distinct-DB sentinel does not

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
1818
import { logger } from "~/services/logger.server";
1919
import { batchTriggerWorker } from "~/v3/batchTriggerWorker.server";
2020
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
21+
import { mintAnchoredRunFriendlyId } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
2122
import { mintBatchFriendlyId } from "~/v3/runOpsMigration/mintBatchFriendlyId.server";
2223
import {
2324
downloadPacketFromObjectStore,
@@ -588,6 +589,9 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
588589
parentRunId,
589590
resumeParentOnCompletion,
590591
batch: { id: batch.id, index: workingIndex },
592+
// Anchor the pre-failed run on the BATCH's residency (same as the happy path) so it
593+
// co-resides with its BatchTaskRun row regardless of a mid-batch mint-flag flip.
594+
runFriendlyId: mintAnchoredRunFriendlyId(batch.friendlyId, item.options?.region),
591595
options: item.options as Record<string, unknown>,
592596
traceContext: options?.traceContext as Record<string, unknown> | undefined,
593597
spanParentAsLink: options?.spanParentAsLink,
@@ -677,6 +681,12 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
677681

678682
const triggerTaskService = new TriggerTaskService();
679683

684+
// Anchor the item's mint on the BATCH's own friendlyId (not a fresh per-org flag read) so
685+
// an org's mint flag flipping between batch creation and this (possibly much later,
686+
// worker-processed) item never splits the item from its BatchTaskRun row. See
687+
// mintAnchoredRunFriendlyId.server.ts.
688+
const runFriendlyId = mintAnchoredRunFriendlyId(batch.friendlyId, item.options?.region);
689+
680690
const result = await triggerTaskService.call(
681691
item.task,
682692
environment,
@@ -695,6 +705,7 @@ export class RunEngineBatchTriggerService extends WithRunEngine {
695705
spanParentAsLink: options?.spanParentAsLink,
696706
batchId: batch.id,
697707
batchIndex: currentIndex,
708+
runFriendlyId,
698709
realtimeStreamsVersion: options?.realtimeStreamsVersion,
699710
triggerSource: options?.triggerSource ?? "api",
700711
triggerAction: options?.triggerAction ?? "trigger",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
// Empty singletons satisfy the module-level wiring imports; the mint method under test is driven
4+
// directly via (service as any) and never touches the DB (same boundary as triggerTask.server.test.ts).
5+
vi.mock("~/db.server", () => ({
6+
prisma: {},
7+
$replica: {},
8+
runOpsNewPrisma: {},
9+
runOpsLegacyPrisma: {},
10+
runOpsNewReplica: {},
11+
runOpsLegacyReplica: {},
12+
}));
13+
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
14+
15+
import { classifyKind, generateRunOpsId, RunId } from "@trigger.dev/core/v3/isomorphic";
16+
import { TriggerFailedTaskService } from "./triggerFailedTask.server";
17+
18+
function buildService() {
19+
return new TriggerFailedTaskService({ prisma: {} as any, engine: {} as any });
20+
}
21+
22+
describe("TriggerFailedTaskService.mintFailedRunFriendlyId", () => {
23+
it("returns the caller-supplied runFriendlyId verbatim (override wins over any mint)", async () => {
24+
const override = RunId.toFriendlyId(generateRunOpsId());
25+
const minted = await (buildService() as any).mintFailedRunFriendlyId({
26+
organizationId: "org_1",
27+
environmentId: "env_1",
28+
runFriendlyId: override,
29+
});
30+
expect(minted).toBe(override);
31+
});
32+
33+
it("without an override, still inherits a run-ops (NEW) parent by id-shape", async () => {
34+
const parentRunFriendlyId = RunId.toFriendlyId(generateRunOpsId());
35+
const minted = await (buildService() as any).mintFailedRunFriendlyId({
36+
organizationId: "org_1",
37+
environmentId: "env_1",
38+
parentRunFriendlyId,
39+
});
40+
expect(classifyKind(minted)).toBe("runOpsId");
41+
});
42+
});

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export type TriggerFailedTaskRequest = {
4343
spanParentAsLink?: boolean;
4444

4545
errorCode?: TaskRunErrorCodes;
46+
47+
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
48+
runFriendlyId?: string;
4649
};
4750

4851
/**
@@ -86,14 +89,20 @@ export class TriggerFailedTaskService {
8689

8790
// Mint a failed run's friendlyId. The id-kind decides which store the run is
8891
// born in (cuid → legacy store, run-ops id → new store); the whole subgraph of a
89-
// run must agree. Root failed runs mint by the environment's setting; child
90-
// failed runs inherit the parent's current store so they never split.
92+
// run must agree. A caller-supplied runFriendlyId (batch-anchored id) wins verbatim;
93+
// otherwise root failed runs mint by the environment's setting and child failed runs
94+
// inherit the parent's current store so they never split.
9195
private async mintFailedRunFriendlyId(args: {
9296
organizationId: string;
9397
environmentId: string;
9498
orgFeatureFlags?: unknown;
9599
parentRunFriendlyId?: string;
100+
runFriendlyId?: string;
96101
}): Promise<string> {
102+
if (args.runFriendlyId) {
103+
return args.runFriendlyId;
104+
}
105+
97106
const mintKind = args.parentRunFriendlyId
98107
? resolveInheritedMintKind(args.parentRunFriendlyId)
99108
: await resolveRunIdMintKind({
@@ -125,6 +134,7 @@ export class TriggerFailedTaskService {
125134
environmentId: request.environment.id,
126135
orgFeatureFlags: request.environment.organization.featureFlags,
127136
parentRunFriendlyId: request.parentRunId,
137+
runFriendlyId: request.runFriendlyId,
128138
});
129139
mintedFriendlyId = failedRunFriendlyId;
130140

@@ -321,6 +331,8 @@ export class TriggerFailedTaskService {
321331
resumeParentOnCompletion?: boolean;
322332
batch?: { id: string; index: number };
323333
errorCode?: TaskRunErrorCodes;
334+
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
335+
runFriendlyId?: string;
324336
}): Promise<string | null> {
325337
// Held for the catch's log line; the in-try `const` is what consumers use.
326338
let mintedFriendlyId: string | undefined;
@@ -335,6 +347,7 @@ export class TriggerFailedTaskService {
335347
// single replica lookup by organizationId only when there is no parent.
336348
orgFeatureFlags: undefined,
337349
parentRunFriendlyId: opts.parentRunId,
350+
runFriendlyId: opts.runFriendlyId,
338351
});
339352
mintedFriendlyId = failedRunFriendlyId;
340353

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
TriggerTraceContext,
1515
} from "@trigger.dev/core/v3";
1616
import {
17-
generateRunOpsId,
1817
parseTraceparent,
1918
RunId,
2019
serializeTraceparent,
@@ -28,6 +27,7 @@ import { handleMetadataPacket } from "~/utils/packets";
2827
import { startSpan } from "~/v3/tracing.server";
2928
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
3029
import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server";
30+
import { mintFriendlyIdForKind } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
3131
import type {
3232
TriggerTaskServiceOptions,
3333
TriggerTaskServiceResult,
@@ -153,9 +153,7 @@ export class RunEngineTriggerTaskService {
153153
orgFeatureFlags: environment.organization.featureFlags,
154154
});
155155

156-
return mintKind === "runOpsId"
157-
? RunId.toFriendlyId(generateRunOpsId(region))
158-
: RunId.generate().friendlyId;
156+
return mintFriendlyIdForKind(mintKind, region);
159157
}
160158

161159
public async call({

apps/webapp/app/v3/runEngineHandlers.server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getEventRepositoryForStore, recordRunDebugLog } from "./eventRepository
2828
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
2929
import { engine } from "./runEngine.server";
3030
import { runStore } from "./runStore.server";
31+
import { mintAnchoredRunFriendlyId } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
3132
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
3233
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
3334
import {
@@ -808,6 +809,14 @@ export function setupBatchQueueCallbacks() {
808809
},
809810
},
810811
async (span) => {
812+
// Anchor every item mint on the BATCH's friendlyId so a mid-batch mint-flag flip
813+
// can't split an item (or pre-failed item) from its BatchTaskRun row.
814+
const mintItemRunFriendlyId = () =>
815+
mintAnchoredRunFriendlyId(
816+
friendlyId,
817+
(item.options as { region?: string } | undefined)?.region
818+
);
819+
811820
const triggerFailedTaskService = new TriggerFailedTaskService({
812821
prisma,
813822
engine,
@@ -837,6 +846,7 @@ export function setupBatchQueueCallbacks() {
837846
parentRunId: meta.parentRunId,
838847
resumeParentOnCompletion: meta.resumeParentOnCompletion,
839848
batch: { id: batchId, index: itemIndex },
849+
runFriendlyId: mintItemRunFriendlyId(),
840850
traceContext: meta.traceContext as Record<string, unknown> | undefined,
841851
spanParentAsLink: meta.spanParentAsLink,
842852
});
@@ -874,6 +884,8 @@ export function setupBatchQueueCallbacks() {
874884
// Normalize payload - for application/store (R2 paths), this passes through as-is
875885
const payload = normalizePayload(item.payload, item.payloadType);
876886

887+
const runFriendlyId = mintItemRunFriendlyId();
888+
877889
const result = await triggerTaskService.call(
878890
item.task,
879891
environment,
@@ -893,6 +905,7 @@ export function setupBatchQueueCallbacks() {
893905
spanParentAsLink: meta.spanParentAsLink,
894906
batchId,
895907
batchIndex: itemIndex,
908+
runFriendlyId,
896909
realtimeStreamsVersion: meta.realtimeStreamsVersion,
897910
planType: meta.planType,
898911
triggerSource: meta.parentRunId ? "sdk" : (meta.triggerSource ?? "api"),
@@ -929,6 +942,7 @@ export function setupBatchQueueCallbacks() {
929942
parentRunId: meta.parentRunId,
930943
resumeParentOnCompletion: meta.resumeParentOnCompletion,
931944
batch: { id: batchId, index: itemIndex },
945+
runFriendlyId: mintItemRunFriendlyId(),
932946
options: item.options as Record<string, unknown>,
933947
traceContext: meta.traceContext as Record<string, unknown> | undefined,
934948
spanParentAsLink: meta.spanParentAsLink,
@@ -1009,6 +1023,7 @@ export function setupBatchQueueCallbacks() {
10091023
parentRunId: meta.parentRunId,
10101024
resumeParentOnCompletion: meta.resumeParentOnCompletion,
10111025
batch: { id: batchId, index: itemIndex },
1026+
runFriendlyId: mintItemRunFriendlyId(),
10121027
options: item.options as Record<string, unknown>,
10131028
traceContext: meta.traceContext as Record<string, unknown> | undefined,
10141029
spanParentAsLink: meta.spanParentAsLink,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
BatchId,
3+
classifyKind,
4+
generateRunOpsId,
5+
parseRunId,
6+
REGION_CODES,
7+
} from "@trigger.dev/core/v3/isomorphic";
8+
import { describe, expect, it } from "vitest";
9+
import { mintAnchoredRunFriendlyId } from "./mintAnchoredRunFriendlyId.server";
10+
11+
describe("mintAnchoredRunFriendlyId", () => {
12+
it("a run-ops (NEW) batch anchor yields a run-ops (NEW) item friendlyId", () => {
13+
const batchFriendlyId = BatchId.toFriendlyId(generateRunOpsId());
14+
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId);
15+
expect(classifyKind(itemFriendlyId)).toBe("runOpsId");
16+
});
17+
18+
it("a cuid (LEGACY) batch anchor yields a cuid (LEGACY) item friendlyId", () => {
19+
const batchFriendlyId = BatchId.generate().friendlyId;
20+
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId);
21+
expect(classifyKind(itemFriendlyId)).toBe("cuid");
22+
});
23+
24+
it("stamps the requested region char into a run-ops id", () => {
25+
const batchFriendlyId = BatchId.toFriendlyId(generateRunOpsId());
26+
const itemFriendlyId = mintAnchoredRunFriendlyId(batchFriendlyId, "us-east-1");
27+
const parsed = parseRunId(itemFriendlyId);
28+
expect(parsed.format).toBe("b32hex");
29+
expect(parsed.format === "b32hex" && parsed.region).toBe(REGION_CODES["us-east-1"]);
30+
});
31+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { generateRunOpsId, RunId, type ResidencyKind } from "@trigger.dev/core/v3/isomorphic";
2+
import { resolveInheritedMintKind } from "./resolveInheritedMintKind.server";
3+
4+
// Shared id-generation branch for every run-mint path: "runOpsId" -> NEW store, "cuid" -> LEGACY.
5+
export function mintFriendlyIdForKind(mintKind: ResidencyKind, region?: string): string {
6+
return mintKind === "runOpsId"
7+
? RunId.toFriendlyId(generateRunOpsId(region))
8+
: RunId.generate().friendlyId;
9+
}
10+
11+
// Anchor a batch item's mint on the BATCH's friendlyId (id-shape, zero I/O), never the per-org
12+
// flag, so the item and its BatchTaskRun stay co-resident across a mid-batch flag flip.
13+
export function mintAnchoredRunFriendlyId(batchFriendlyId: string, region?: string): string {
14+
return mintFriendlyIdForKind(resolveInheritedMintKind(batchFriendlyId), region);
15+
}

apps/webapp/app/v3/runOpsMigration/runOpsSplitReadGate.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@ export function computeRunOpsSplitReadEnabled(args: {
66
controlPlaneReplica: unknown;
77
hasNewUrl: boolean;
88
hasLegacyUrl: boolean;
9+
logger?: { warn: (msg: string, meta?: Record<string, unknown>) => void };
910
}): boolean {
1011
const newIsDistinctDedicatedClient =
1112
args.newReplica !== args.controlPlaneWriter && args.newReplica !== args.controlPlaneReplica;
1213

13-
return newIsDistinctDedicatedClient && args.hasNewUrl && args.hasLegacyUrl;
14+
const enabled = newIsDistinctDedicatedClient && args.hasNewUrl && args.hasLegacyUrl;
15+
16+
// Configured for split but the identity check failed: fan-out is being silently disabled.
17+
if (!newIsDistinctDedicatedClient && args.hasNewUrl && args.hasLegacyUrl) {
18+
args.logger?.warn(
19+
"run-ops split read fan-out is configured (RUN_OPS_DATABASE_URL and " +
20+
"RUN_OPS_LEGACY_DATABASE_URL are both set) but the NEW client is not a distinct " +
21+
"instance from the control-plane client; read fan-out is silently disabled."
22+
);
23+
}
24+
25+
return enabled;
1426
}

0 commit comments

Comments
 (0)