diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 2b1fba86980..0c3a03148ea 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -2020,6 +2020,12 @@ const EnvironmentSchema = z // (stale or fresh) resolves to the same kind for the whole window. See mintFlipGrace.ts. RUN_OPS_MINT_FLIP_GRACE_MS: z.coerce.number().int().default(90_000), + // Per-organization waitpoint coordinator cutover. The org's waitpointSystem flag wins; + // this is the fallback when the org has no override. Read only at waitpoint mint time. + WAITPOINT_SYSTEM_DEFAULT: z.enum(["legacy", "redis"]).default("legacy"), + WAITPOINT_MINT_FLAG_CACHE_TTL_MS: z.coerce.number().int().default(30_000), + WAITPOINT_MINT_FLAG_CACHE_MAX_ENTRIES: z.coerce.number().int().default(10_000), + // Session replication (Postgres → ClickHouse sessions_v1). Shares Redis // with the runs replicator for leader locking but has its own slot and // publication so the two consume independently. diff --git a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts index aac8a5445bd..68f76e5f3f9 100644 --- a/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointPresenter.server.ts @@ -2,6 +2,7 @@ import { isWaitpointOutputTimeout, prettyPrintPacket } from "@trigger.dev/core/v import { type PrismaClientOrTransaction } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { generateHttpCallbackUrl } from "~/services/httpCallback.server"; +import { parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; import { logger } from "~/services/logger.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { runStore as defaultRunStore } from "~/v3/runStore.server"; @@ -117,7 +118,14 @@ export class WaitpointPresenter extends BasePresenter { } } - const connectedRunIds = await this.#connectedRunFriendlyIds(waitpoint.id); + // The connected-runs display is built from a Postgres table that only the Postgres + // block-edge write populates. A store-resident waitpoint keeps its edges elsewhere, so + // the query would answer an empty list, which reads as "nothing is blocked on this" + // rather than "this cannot be shown". Report the difference instead of guessing. + const connectedRunsAvailable = parseWaitpointId(waitpoint.id).format === "legacy"; + const connectedRunIds = connectedRunsAvailable + ? await this.#connectedRunFriendlyIds(waitpoint.id) + : []; const connectedRuns: NextRunListItem[] = []; if (connectedRunIds.length > 0) { @@ -164,6 +172,7 @@ export class WaitpointPresenter extends BasePresenter { createdAt: waitpoint.createdAt, tags: waitpoint.tags, connectedRuns, + connectedRunsAvailable, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx index 8903e53ede9..b578ffaf751 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens.$waitpointParam/route.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { LinkButton } from "~/components/primitives/Buttons"; import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -135,22 +136,28 @@ export default function Page() { Related runs - + {!waitpoint.connectedRunsAvailable ? ( + + Related runs aren't available for this token. + + ) : ( + + )} {waitpoint.status === "WAITING" && ( diff --git a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts index 024779ac666..3d28861fbb9 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts @@ -1,4 +1,5 @@ import { json } from "@remix-run/server-runtime"; +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { z } from "zod"; import { CreateInputStreamWaitpointRequestBody, @@ -82,7 +83,14 @@ const { action, loader } = createActionApiRoute( // Create the waitpoint. Co-locate it with the owning run (run-ops split) so a run-ops id // run's input-stream waitpoint lands on the run's DB and its block edge resolves. + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: authentication.environment.organizationId, + id: authentication.environment.id, + orgFeatureFlags: authentication.environment.organization.featureFlags, + }); + const result = await engine.createManualWaitpoint({ + waitpointMintKind, runId: run.id, environmentId: authentication.environment.id, projectId: authentication.environment.projectId, diff --git a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts index c00ff51b3be..001a25e892a 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts @@ -1,4 +1,5 @@ import { json } from "@remix-run/server-runtime"; +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { CreateSessionStreamWaitpointRequestBody, type CreateSessionStreamWaitpointResponseBody, @@ -103,7 +104,14 @@ const { action, loader } = createActionApiRoute( // Create the waitpoint. Co-locate it with the owning run (run-ops split) so a run-ops id // run's session-stream waitpoint lands on the run's DB and its block edge resolves. + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: authentication.environment.organizationId, + id: authentication.environment.id, + orgFeatureFlags: authentication.environment.organization.featureFlags, + }); + const result = await engine.createManualWaitpoint({ + waitpointMintKind, runId: run.id, environmentId: authentication.environment.id, projectId: authentication.environment.projectId, diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts index f67f0860f2b..99c28d77e0f 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.ts @@ -1,4 +1,5 @@ import { json } from "@remix-run/server-runtime"; +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { CreateWaitpointTokenRequestBody, type CreateWaitpointTokenResponseBody, @@ -104,7 +105,14 @@ const { action } = createActionApiRoute( } } + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: authentication.environment.organizationId, + id: authentication.environment.id, + orgFeatureFlags: authentication.environment.organization.featureFlags, + }); + const result = await engine.createManualWaitpoint({ + waitpointMintKind, environmentId: authentication.environment.id, projectId: authentication.environment.projectId, idempotencyKey: body.idempotencyKey, diff --git a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts index c7a8c3c5619..957f8ba1a74 100644 --- a/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts +++ b/apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts @@ -1,4 +1,5 @@ import type { TypedResponse } from "@remix-run/server-runtime"; +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { json } from "@remix-run/server-runtime"; import type { WaitForDurationResponseBody } from "@trigger.dev/core/v3"; import { WaitForDurationRequestBody } from "@trigger.dev/core/v3"; @@ -41,7 +42,14 @@ const { action } = createActionApiRoute( ? resolveIdempotencyKeyTTL(body.idempotencyKeyTTL) : undefined; + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: authentication.environment.organizationId, + id: authentication.environment.id, + orgFeatureFlags: authentication.environment.organization.featureFlags, + }); + const { waitpoint } = await engine.createDateTimeWaitpoint({ + waitpointMintKind, // Co-locate the waitpoint with the run that blocks on it (run-ops split): a run-ops run lives // on the dedicated DB, but the minted waitpoint id is always a cuid, so without the run id // the waitpoint would route to the control-plane DB and the block edge would never resolve. diff --git a/apps/webapp/app/runEngine/services/batchTrigger.server.ts b/apps/webapp/app/runEngine/services/batchTrigger.server.ts index 5e29d158925..59d5f594462 100644 --- a/apps/webapp/app/runEngine/services/batchTrigger.server.ts +++ b/apps/webapp/app/runEngine/services/batchTrigger.server.ts @@ -1,3 +1,4 @@ +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { type BatchTriggerTaskV2RequestBody, type BatchTriggerTaskV3RequestBody, @@ -194,6 +195,11 @@ export class RunEngineBatchTriggerService extends WithRunEngine { environmentId: environment.id, projectId: environment.projectId, organizationId: environment.organizationId, + waitpointMintKind: await resolveWaitpointMintKind({ + organizationId: environment.organizationId, + id: environment.id, + orgFeatureFlags: environment.organization.featureFlags, + }), }); } @@ -285,6 +291,11 @@ export class RunEngineBatchTriggerService extends WithRunEngine { environmentId: environment.id, projectId: environment.projectId, organizationId: environment.organizationId, + waitpointMintKind: await resolveWaitpointMintKind({ + organizationId: environment.organizationId, + id: environment.id, + orgFeatureFlags: environment.organization.featureFlags, + }), }); } diff --git a/apps/webapp/app/runEngine/services/createBatch.server.ts b/apps/webapp/app/runEngine/services/createBatch.server.ts index 0289e68e2c7..b3f652b2f89 100644 --- a/apps/webapp/app/runEngine/services/createBatch.server.ts +++ b/apps/webapp/app/runEngine/services/createBatch.server.ts @@ -1,4 +1,5 @@ import type { InitializeBatchOptions } from "@internal/run-engine"; +import { resolveWaitpointMintKind } from "~/v3/waitpointMigration/waitpointMintKind.server"; import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3"; import { RunId } from "@trigger.dev/core/v3/isomorphic"; import { type BatchTaskRun, Prisma } from "@trigger.dev/database"; @@ -137,6 +138,11 @@ export class CreateBatchService extends WithRunEngine { environmentId: environment.id, projectId: environment.projectId, organizationId: environment.organizationId, + waitpointMintKind: await resolveWaitpointMintKind({ + organizationId: environment.organizationId, + id: environment.id, + orgFeatureFlags: environment.organization.featureFlags, + }), }); } diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index d3320dbc219..dc3b8d98ef8 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -28,6 +28,10 @@ import { parseDelay } from "~/utils/delays"; import { removeNullBytesFromKey } from "~/utils/nullBytes"; import { handleMetadataPacket } from "~/utils/packets"; import { startSpan } from "~/v3/tracing.server"; +import { + resolveWaitpointMintKind, + type WaitpointMintKind, +} from "~/v3/waitpointMigration/waitpointMintKind.server"; import { mintFriendlyIdForKind } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server"; import { resolveRunMintTarget } from "~/v3/runOpsMigration/resolveRunMintTarget.server"; import type { @@ -653,10 +657,16 @@ export class RunEngineTriggerTaskService { event.setAttribute("taskRunId", runFriendlyId); const payloadPacket = await this.payloadProcessor.process(triggerRequest); + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: environment.organizationId, + id: environment.id, + orgFeatureFlags: environment.organization.featureFlags, + }); const engineTriggerInput = this.#buildEngineTriggerInput({ runFriendlyId, environment, + waitpointMintKind, idempotencyKey, idempotencyKeyExpiresAt, body, @@ -733,10 +743,16 @@ export class RunEngineTriggerTaskService { } const payloadPacket = await this.payloadProcessor.process(triggerRequest); + const waitpointMintKind = await resolveWaitpointMintKind({ + organizationId: environment.organizationId, + id: environment.id, + orgFeatureFlags: environment.organization.featureFlags, + }); const baseEngineInput = this.#buildEngineTriggerInput({ runFriendlyId, environment, + waitpointMintKind, idempotencyKey, idempotencyKeyExpiresAt, body, @@ -899,6 +915,7 @@ export class RunEngineTriggerTaskService { #buildEngineTriggerInput(args: { runFriendlyId: string; environment: AuthenticatedEnvironment; + waitpointMintKind: WaitpointMintKind; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; body: TriggerTaskRequest["body"]; @@ -988,6 +1005,7 @@ export class RunEngineTriggerTaskService { ? { id: args.options.batchId, index: args.options.batchIndex ?? 0 } : undefined, resumeParentOnCompletion: args.body.options?.resumeParentOnCompletion, + waitpointMintKind: args.waitpointMintKind, depth: args.depth, metadata: args.metadataPacket?.data, metadataType: args.metadataPacket?.dataType, diff --git a/apps/webapp/app/v3/featureFlags.ts b/apps/webapp/app/v3/featureFlags.ts index 3dccd5c4cc7..b7c872f0a5a 100644 --- a/apps/webapp/app/v3/featureFlags.ts +++ b/apps/webapp/app/v3/featureFlags.ts @@ -37,6 +37,9 @@ export const FEATURE_FLAG = { runOpsMintShardSetFlippedAt: "runOpsMintShardSetFlippedAt", // Fleet-wide pin for the complete cutover. Beats every per-org and per-env pin. runOpsMintShardOverride: "runOpsMintShardOverride", + // Per-organization waitpoint coordinator selection. Read ONLY at waitpoint mint time; + // every later operation on a waitpoint routes by its id shape, never by this flag. + waitpointSystem: "waitpointSystem", queueMetricsUiEnabled: "queueMetricsUiEnabled", // Build path for CLI deploys, resolved by DeploymentService.getDeploySettings. deployBuildPath: "deployBuildPath", @@ -101,6 +104,7 @@ export const FeatureFlagCatalog = { // Per-org run-ops-id mint cutover. Defaults to "cuid"; only honored when // RUN_OPS_MINT_ENABLED is on AND isSplitEnabled() is true. [FEATURE_FLAG.runOpsMintKind]: z.enum(["cuid", "runOpsId"]), + [FEATURE_FLAG.waitpointSystem]: z.enum(["legacy", "redis"]), // Grace-linger stamp: the previously-effective kind and the flip timestamp, written // by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS). [FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]), diff --git a/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.server.ts b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.server.ts new file mode 100644 index 00000000000..75c00d328f3 --- /dev/null +++ b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.server.ts @@ -0,0 +1,66 @@ +import { $replica } from "~/db.server"; +import { env } from "~/env.server"; +import { BoundedTtlCache } from "~/services/realtime/boundedTtlCache"; +import { singleton } from "~/utils/singleton"; +import { logger } from "~/services/logger.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { computeWaitpointMintKind, type WaitpointMintKind } from "./waitpointMintKind.js"; + +export type { WaitpointMintKind }; + +// The two unions are declared separately, because the engine never imports from the +// webapp. Nothing pins them together here on purpose: every call site passes this value +// into an engine method, so a drift fails at those call sites, where the error is local +// to the code that actually broke. + +type WaitpointSystemFlag = "legacy" | "redis"; + +const mintCache = singleton( + "waitpointMintCache", + () => + new BoundedTtlCache( + env.WAITPOINT_MINT_FLAG_CACHE_TTL_MS, + env.WAITPOINT_MINT_FLAG_CACHE_MAX_ENTRIES + ) +); + +// ENV-BOUND wrapper — the only place env and $replica are read. +export async function resolveWaitpointMintKind(environment: { + organizationId: string; + id: string; + /** Pass environment.organization.featureFlags from the call site. */ + orgFeatureFlags?: unknown; +}): Promise { + return computeWaitpointMintKind(environment, { + globalDefault: env.WAITPOINT_SYSTEM_DEFAULT, + onError: (error) => + logger.error("[waitpointMintKind] flag read failed; minting legacy (fail-safe)", { error }), + flag: async (orgId, orgFeatureFlags) => { + // null is a cached "this org has no override", which must stay distinct from a miss: + // BoundedTtlCache reports a stored undefined as a miss, so never store undefined. + const cached = mintCache.get(orgId); + if (cached !== undefined) { + return cached ?? undefined; + } + + // Hot-path pass-through: only read the replica when the caller passed no org flags. + const overrides = + orgFeatureFlags !== undefined + ? orgFeatureFlags + : ( + await $replica.organization.findFirst({ + where: { id: orgId }, + select: { featureFlags: true }, + }) + )?.featureFlags; + + const value = (overrides as Record | null | undefined)?.[ + FEATURE_FLAG.waitpointSystem + ]; + const resolved = value === "redis" || value === "legacy" ? value : null; + + mintCache.set(orgId, resolved); + return resolved ?? undefined; + }, + }); +} diff --git a/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.test.ts b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.test.ts new file mode 100644 index 00000000000..79c0dc37fe0 --- /dev/null +++ b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { computeWaitpointMintKind } from "./waitpointMintKind"; + +const environment = { organizationId: "org_1", id: "env_1" }; + +describe("computeWaitpointMintKind", () => { + it("returns legacy when the org has no override and the default is legacy", async () => { + const kind = await computeWaitpointMintKind(environment, { + globalDefault: "legacy", + flag: async () => undefined, + }); + + expect(kind).toBe("legacy"); + }); + + it("returns store when the org override is redis", async () => { + const kind = await computeWaitpointMintKind(environment, { + globalDefault: "legacy", + flag: async () => "redis", + }); + + expect(kind).toBe("store"); + }); + + it("lets an explicit org legacy override beat a redis global default", async () => { + const kind = await computeWaitpointMintKind(environment, { + globalDefault: "redis", + flag: async () => "legacy", + }); + + expect(kind).toBe("legacy"); + }); + + it("falls back to the global default when the org has no override", async () => { + const kind = await computeWaitpointMintKind(environment, { + globalDefault: "redis", + flag: async () => undefined, + }); + + expect(kind).toBe("store"); + }); + + it("fails safe to legacy when the flag read throws", async () => { + const kind = await computeWaitpointMintKind(environment, { + globalDefault: "redis", + flag: async () => { + throw new Error("replica down"); + }, + }); + + expect(kind).toBe("legacy"); + }); + + it("hands the pre-loaded org flags to the flag reader", async () => { + const flag = vi.fn(async () => "redis" as const); + + await computeWaitpointMintKind( + { ...environment, orgFeatureFlags: { waitpointSystem: "redis" } }, + { globalDefault: "legacy", flag } + ); + + expect(flag).toHaveBeenCalledWith("org_1", { waitpointSystem: "redis" }); + }); +}); diff --git a/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.ts b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.ts new file mode 100644 index 00000000000..3b500f5f65c --- /dev/null +++ b/apps/webapp/app/v3/waitpointMigration/waitpointMintKind.ts @@ -0,0 +1,37 @@ +// Pure: no server-only imports, so a test can drive this without loading env.server. +/** + * Which coordinator mints a NEW waitpoint. Consulted at the mint and never again: every + * later operation routes by id shape. A flip therefore changes only where the NEXT + * waitpoint is born, which is why this needs no flip-grace machinery. + */ +export type WaitpointMintKind = "legacy" | "store"; + +/** The flag's vocabulary, deliberately not the coordinator's. */ +type WaitpointSystemFlag = "legacy" | "redis"; + +type MintKindDeps = { + globalDefault: WaitpointSystemFlag; + /** Undefined when the org has no override. Must not hit the DB when given org flags. */ + flag: ( + orgId: string, + orgFeatureFlags: unknown | undefined + ) => Promise; + /** Surfaced instead of logged, so this module pulls in no server-only import. */ + onError?: (error: unknown) => void; +}; + +// PURE CORE — no env import; the tests drive this directly. +export async function computeWaitpointMintKind( + environment: { organizationId: string; id: string; orgFeatureFlags?: unknown }, + deps: MintKindDeps +): Promise { + try { + const perOrg = await deps.flag(environment.organizationId, environment.orgFeatureFlags); + return (perOrg ?? deps.globalDefault) === "redis" ? "store" : "legacy"; + } catch (error) { + // Fail safe, as computeRunIdMintKind does: a flag-read failure degrades to the old + // path rather than becoming a trigger-path outage. + deps.onError?.(error); + return "legacy"; + } +} diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 3880e304573..d9c66535bff 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ include: [ "test/**/*.test.ts", "app/v3/runOpsMigration/**/*.test.ts", + "app/v3/waitpointMigration/**/*.test.ts", "app/v3/runStore.server.test.ts", "app/v3/utils/**/*.test.ts", "app/v3/services/bulk/**/*.test.ts", diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 0ff2b8068de..2909fac68e0 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -24,9 +24,10 @@ import { import type { TaskRunError } from "@trigger.dev/core/v3/schemas"; import { generateInternalId, + deriveWaitpointIdFromAnchor, parseNaturalLanguageDurationInMs, + parseWaitpointId, RunId, - mintWaitpointIdFor, type ShardKey, } from "@trigger.dev/core/v3/isomorphic"; import { @@ -93,6 +94,11 @@ import { } from "./controlPlaneResolver.js"; import { TtlSystem } from "./systems/ttlSystem.js"; import { WaitpointSystem } from "./systems/waitpointSystem.js"; +import { LegacyPostgresWaitpointCoordinator } from "./waitpointCoordinator/legacyPostgresCoordinator.js"; +import { WaitpointRouterCoordinator } from "./waitpointCoordinator/routerCoordinator.js"; +import { StoreWaitpointCoordinatorArm } from "./waitpointCoordinator/storeArm.js"; +import { WaitpointStoreCoordinator } from "./waitpointCoordinator/storeCoordinator.js"; +import type { WaitpointMintKind } from "./waitpointCoordinator/types.js"; import type { EngineWorker, HeartbeatTimeouts, @@ -131,6 +137,7 @@ export class RunEngine { runAttemptSystem: RunAttemptSystem; dequeueSystem: DequeueSystem; waitpointSystem: WaitpointSystem; + private waitpointStoreCoordinator?: WaitpointStoreCoordinator; batchSystem: BatchSystem; enqueueSystem: EnqueueSystem; checkpointSystem: CheckpointSystem; @@ -419,10 +426,34 @@ export class RunEngine { externalDeploymentParkDeadlineMs: options.externalDeploymentParkDeadlineMs, }); + this.waitpointStoreCoordinator = this.options.waitpointStore + ? new WaitpointStoreCoordinator({ + redisOptions: this.options.waitpointStore.redis, + logger: this.logger, + }) + : undefined; + this.waitpointSystem = new WaitpointSystem({ resources, executionSnapshotSystem: this.executionSnapshotSystem, enqueueSystem: this.enqueueSystem, + coordinator: new WaitpointRouterCoordinator({ + meter: this.meter, + legacy: new LegacyPostgresWaitpointCoordinator({ + runStore: this.runStore, + prisma: this.prisma, + logger: this.logger, + }), + store: this.waitpointStoreCoordinator + ? new StoreWaitpointCoordinatorArm({ + store: this.waitpointStoreCoordinator, + runStore: this.runStore, + logger: this.logger, + meter: this.meter, + }) + : undefined, + logger: this.logger, + }), }); this.ttlSystem = new TtlSystem({ @@ -858,6 +889,7 @@ export class RunEngine { replayedFromTaskRunFriendlyId, batch, resumeParentOnCompletion, + waitpointMintKind, depth, metadata, metadataType, @@ -980,6 +1012,23 @@ export class RunEngine { let taskRun: TaskRun & { associatedWaitpoint: Waitpoint | null }; const taskRunId = RunId.fromFriendlyId(friendlyId); + + // Mint the RUN waitpoint's identity BEFORE the run is created, so the decision to + // block the parent never depends on a Postgres relation that the store path does + // not write. Keying the block step off the row instead would silently stop + // suspending parents the moment a waitpoint stopped living in Postgres. + const associatedWaitpointData = + resumeParentOnCompletion && parentTaskRunId + ? this.waitpointSystem.buildRunAssociatedWaitpoint({ + projectId: environment.project.id, + environmentId: environment.id, + anchorRunId: taskRunId, + mintKind: waitpointMintKind, + }) + : undefined; + const associatedWaitpointRidesTheCreate = + associatedWaitpointData !== undefined && + parseWaitpointId(associatedWaitpointData.id).format === "legacy"; const initialSnapshotId = generateInternalId(); // App-level replacement for the dropped TaskRun env/project Cascade FKs. @@ -1089,16 +1138,14 @@ export class RunEngine { workerId, runnerId, }, - // Only create waitpoint if parent is waiting for this run to complete - // For standalone triggers (no waiting parent), waitpoint is created lazily if needed later - associatedWaitpoint: - resumeParentOnCompletion && parentTaskRunId - ? this.waitpointSystem.buildRunAssociatedWaitpoint({ - projectId: environment.project.id, - environmentId: environment.id, - anchorRunId: taskRunId, - }) - : undefined, + // Only create the waitpoint if a parent is waiting for this run. A standalone + // trigger gets one lazily later, if anything ever needs it. + // + // The store path deliberately passes nothing here: its waitpoint is created + // after the run commits, so the run's own insert carries no waitpoint row. + associatedWaitpoint: associatedWaitpointRidesTheCreate + ? associatedWaitpointData + : undefined, }, tx ); @@ -1141,8 +1188,18 @@ export class RunEngine { span.setAttribute("runId", taskRun.id); + // The store path's waitpoint is created here, after the run commits. Create-if-absent + // on an id derived from the run means a retry recomputes the same id, so this is + // idempotent and needs no lock. + if (associatedWaitpointData && !associatedWaitpointRidesTheCreate) { + await this.waitpointSystem.createRunAssociatedWaitpoint({ + runId: taskRun.id, + data: associatedWaitpointData, + }); + } + //triggerAndWait or batchTriggerAndWait - if (resumeParentOnCompletion && parentTaskRunId && taskRun.associatedWaitpoint) { + if (resumeParentOnCompletion && parentTaskRunId && associatedWaitpointData) { if (batch) { // Batch path: lockless insert. The parent is already EXECUTING_WITH_WAITPOINTS // from blockRunWithCreatedBatch, so we only need to insert the TaskRunWaitpoint @@ -1150,17 +1207,21 @@ export class RunEngine { // processing large batches with high concurrency. await this.waitpointSystem.blockRunWithWaitpointLockless({ runId: parentTaskRunId, - waitpoints: taskRun.associatedWaitpoint.id, - projectId: taskRun.associatedWaitpoint.projectId, + waitpoints: associatedWaitpointData.id, + projectId: associatedWaitpointData.projectId, batch, + // Derived, not looked up: the parent's BATCH waitpoint id is a pure function + // of the batch id, and the store arm needs it to assert the parent's pending + // set stays open for the whole absorb. + batchWaitpointId: deriveWaitpointIdFromAnchor(batch.id, "BATCH"), }); } else { // Single triggerAndWait: acquire the parent run lock to safely transition // the snapshot and insert the waitpoint await this.waitpointSystem.blockRunWithWaitpoint({ runId: parentTaskRunId, - waitpoints: taskRun.associatedWaitpoint.id, - projectId: taskRun.associatedWaitpoint.projectId, + waitpoints: associatedWaitpointData.id, + projectId: associatedWaitpointData.projectId, organizationId: environment.organization.id, batch, workerId, @@ -1319,6 +1380,7 @@ export class RunEngine { rootTaskRunId, depth, resumeParentOnCompletion, + waitpointMintKind, batch, traceId, spanId, @@ -1345,6 +1407,8 @@ export class RunEngine { /** Depth in the task tree (0 for root, parentDepth+1 for children). */ depth?: number; resumeParentOnCompletion?: boolean; + /** Which coordinator mints the associated waitpoint. Absent means legacy. */ + waitpointMintKind?: WaitpointMintKind; batch?: { id: string; index: number }; traceId?: string; spanId?: string; @@ -1377,15 +1441,19 @@ export class RunEngine { // App-level replacement for the dropped TaskRun env/project Cascade FKs. await this.controlPlaneResolver.assertEnvExists(environment.id); - // Build associated waitpoint data if parent is waiting for this run + // Minted before the create, for the same reason as the trigger path: the decision to + // block the parent must not depend on a row the store path never writes. const waitpointData = resumeParentOnCompletion && parentTaskRunId ? this.waitpointSystem.buildRunAssociatedWaitpoint({ projectId: environment.project.id, environmentId: environment.id, anchorRunId: taskRunId, + mintKind: waitpointMintKind, }) : undefined; + const waitpointRidesTheCreate = + waitpointData !== undefined && parseWaitpointId(waitpointData.id).format === "legacy"; // No execution snapshot is needed: this run never gets dequeued, executed, // or heartbeated, so nothing will call getLatestExecutionSnapshot on it. @@ -1419,19 +1487,26 @@ export class RunEngine { resumeParentOnCompletion, taskEventStore, }, - associatedWaitpoint: waitpointData, + associatedWaitpoint: waitpointRidesTheCreate ? waitpointData : undefined, }, undefined ); span.setAttribute("runId", taskRun.id); + if (waitpointData && !waitpointRidesTheCreate) { + await this.waitpointSystem.createRunAssociatedWaitpoint({ + runId: taskRun.id, + data: waitpointData, + }); + } + // If parent is waiting, block it with the waitpoint then immediately // complete it with the error output so the parent can resume. - if (resumeParentOnCompletion && parentTaskRunId && taskRun.associatedWaitpoint) { + if (resumeParentOnCompletion && parentTaskRunId && waitpointData) { await this.waitpointSystem.blockRunAndCompleteWaitpoint({ runId: parentTaskRunId, - waitpointId: taskRun.associatedWaitpoint.id, + waitpointId: waitpointData.id, output: { value: JSON.stringify(error), isError: true }, projectId: environment.project.id, organizationId: environment.organization.id, @@ -1787,6 +1862,7 @@ export class RunEngine { completedAfter, idempotencyKey, idempotencyKeyExpiresAt, + waitpointMintKind, }: { /** The run that will block on this waitpoint. Co-locates the waitpoint with the run's DB. */ runId?: string; @@ -1795,6 +1871,8 @@ export class RunEngine { completedAfter: Date; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; + /** Which coordinator mints this waitpoint. Resolved from the org flag by the caller. */ + waitpointMintKind?: WaitpointMintKind; }) { return this.waitpointSystem.createDateTimeWaitpoint({ runId, @@ -1803,6 +1881,7 @@ export class RunEngine { completedAfter, idempotencyKey, idempotencyKeyExpiresAt, + waitpointMintKind, }); } @@ -1818,6 +1897,7 @@ export class RunEngine { timeout, tags, standaloneResidency, + waitpointMintKind, standaloneShardKey, }: { /** The run that will block on this waitpoint. Co-locates the waitpoint with the run's DB. */ @@ -1830,6 +1910,8 @@ export class RunEngine { tags?: string[]; /** Standalone-token residency (no owning run) from the env mint kind; ignored when `runId` is set. */ standaloneResidency?: "NEW" | "LEGACY"; + /** Which coordinator mints this waitpoint. Resolved from the org flag by the caller. */ + waitpointMintKind?: WaitpointMintKind; standaloneShardKey?: ShardKey; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { return this.waitpointSystem.createManualWaitpoint({ @@ -1841,6 +1923,7 @@ export class RunEngine { timeout, tags, standaloneResidency, + waitpointMintKind, standaloneShardKey, }); } @@ -1854,6 +1937,7 @@ export class RunEngine { environmentId, projectId, organizationId, + waitpointMintKind, tx, }: { runId: string; @@ -1861,26 +1945,23 @@ export class RunEngine { environmentId: string; projectId: string; organizationId: string; + waitpointMintKind?: WaitpointMintKind; tx?: PrismaClientOrTransaction; }): Promise { - try { - const waitpoint = await this.runStore.createWaitpoint( - { - data: { - // From the batch, not the blocked run: the create passes only completedByBatchId, - // which is the owner the router validates against. - ...mintWaitpointIdFor(batchId), - type: "BATCH", - idempotencyKey: batchId, - userProvidedIdempotencyKey: false, - completedByBatchId: batchId, - environmentId, - projectId, - }, - }, - tx - ); + const waitpoint = await this.waitpointSystem.createBatchWaitpoint({ + batchId, + environmentId, + projectId, + mintKind: waitpointMintKind, + tx, + }); + + // Duplicate batch: the coordinator already reported it. + if (!waitpoint) { + return null; + } + try { await this.blockRunWithWaitpoint({ runId, waitpoints: waitpoint.id, @@ -1889,19 +1970,17 @@ export class RunEngine { batch: { id: batchId }, // No tx: the block edge routes to the run's owning DB, not the control-plane tx. }); - - return waitpoint; } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - // duplicate idempotency key - if (error.code === "P2002") { - return null; - } else { - throw error; - } + // The previous shape wrapped the create AND the block in one catch, so a P2002 from + // the block step also returned null. Kept deliberately: narrowing it here would be a + // behaviour change smuggled into an extraction. + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return null; } throw error; } + + return waitpoint; } async tryCompleteBatch({ batchId }: { batchId: string }): Promise { @@ -2410,8 +2489,12 @@ export class RunEngine { const supportResults = await Promise.allSettled([ this.runLock.quit(), this.debounceSystem.quit(), + this.waitpointStoreCoordinator?.quit(), ]); - this.#logShutdownFailures(["runLock.quit", "debounceSystem.quit"], supportResults); + this.#logShutdownFailures( + ["runLock.quit", "debounceSystem.quit", "waitpointStore.quit"], + supportResults + ); // RunLocker/Redlock owns this client and normally closes it. Do not send a second QUIT, // but force-disconnect if Redlock failed to leave the connection in its terminal state. diff --git a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts index 86122e7fe52..5809f8e2279 100644 --- a/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/waitpointSystem.ts @@ -12,9 +12,13 @@ import type { import { assertNever } from "assert-never"; import { sendNotificationToWorker } from "../eventBus.js"; import { isFinalRunStatus } from "../statuses.js"; -import { LegacyPostgresWaitpointCoordinator } from "../waitpointCoordinator/legacyPostgresCoordinator.js"; import { buildCompletedWaitpointRecords } from "../waitpointCoordinator/completedWaitpointRecords.js"; -import type { RunBlockEdge, WaitpointCoordinator } from "../waitpointCoordinator/types.js"; +import type { + AssociatedWaitpointData, + RunBlockEdge, + WaitpointCoordinator, + WaitpointMintKind, +} from "../waitpointCoordinator/types.js"; import type { EnqueueSystem } from "./enqueueSystem.js"; import type { ExecutionSnapshotSystem } from "./executionSnapshotSystem.js"; import { getLatestExecutionSnapshot } from "./executionSnapshotSystem.js"; @@ -24,6 +28,8 @@ export type WaitpointSystemOptions = { resources: SystemResources; executionSnapshotSystem: ExecutionSnapshotSystem; enqueueSystem: EnqueueSystem; + /** Which coordinator owns waitpoint state. The engine supplies a router over both arms. */ + coordinator: WaitpointCoordinator; }; type WaitpointContinuationWaitpoint = Pick; @@ -52,11 +58,7 @@ export class WaitpointSystem { this.$ = options.resources; this.executionSnapshotSystem = options.executionSnapshotSystem; this.enqueueSystem = options.enqueueSystem; - this.coordinator = new LegacyPostgresWaitpointCoordinator({ - runStore: this.$.runStore, - prisma: this.$.prisma, - logger: this.$.logger, - }); + this.coordinator = options.coordinator; } public async clearBlockingWaitpoints({ @@ -145,6 +147,7 @@ export class WaitpointSystem { completedAfter, idempotencyKey, idempotencyKeyExpiresAt, + waitpointMintKind, }: { runId?: string; projectId: string; @@ -152,8 +155,10 @@ export class WaitpointSystem { completedAfter: Date; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; + waitpointMintKind?: WaitpointMintKind; }) { const result = await this.coordinator.createDateTimeWaitpoint({ + mintKind: waitpointMintKind ?? "legacy", runId, projectId, environmentId, @@ -188,11 +193,13 @@ export class WaitpointSystem { timeout, tags, standaloneResidency, + waitpointMintKind, standaloneShardKey, }: { runId?: string; environmentId: string; projectId: string; + waitpointMintKind?: WaitpointMintKind; idempotencyKey?: string; idempotencyKeyExpiresAt?: Date; timeout?: Date; @@ -204,6 +211,7 @@ export class WaitpointSystem { standaloneShardKey?: ShardKey; }): Promise<{ waitpoint: Waitpoint; isCached: boolean }> { const result = await this.coordinator.createManualWaitpoint({ + mintKind: waitpointMintKind ?? "legacy", runId, environmentId, projectId, @@ -389,6 +397,7 @@ export class WaitpointSystem { timeout, spanIdToComplete, batch, + batchWaitpointId, }: { runId: string; waitpoints: string | string[]; @@ -396,6 +405,8 @@ export class WaitpointSystem { timeout?: Date; spanIdToComplete?: string; batch: { id: string; index?: number }; + /** The parent's BATCH waitpoint, so the store arm can assert it is still pending. */ + batchWaitpointId?: string; }): Promise { const $waitpoints = typeof waitpoints === "string" ? [waitpoints] : waitpoints; @@ -409,6 +420,7 @@ export class WaitpointSystem { spanIdToComplete, batchId: batch.id, batchIndex: batch.index, + batchWaitpointId, }); // Schedule timeout jobs if needed @@ -739,22 +751,61 @@ export class WaitpointSystem { }); // end of runlock } + /** The BATCH waitpoint for a batch. Returns null when the batch already has one. */ + public async createBatchWaitpoint(params: { + batchId: string; + environmentId: string; + projectId: string; + mintKind?: WaitpointMintKind; + tx?: PrismaClientOrTransaction; + }): Promise { + return this.coordinator.createBatchWaitpoint({ + ...params, + mintKind: params.mintKind ?? "legacy", + }); + } + + /** + * Mint the RUN waitpoint's data for a run that a parent will block on. + * + * A store mint derives the id from the anchor run's own id body, so the id is a pure + * function of the run id and create-if-absent needs no lock. Derivation only works when + * the run itself carries a run-ops id, so a legacy-shaped run keeps a legacy waitpoint + * even in a flipped organization, which is the coexistence rule the id routing relies on. + */ public buildRunAssociatedWaitpoint({ projectId, environmentId, anchorRunId, + mintKind, }: { projectId: string; environmentId: string; anchorRunId: string; + mintKind?: WaitpointMintKind; }) { return this.coordinator.mintAssociatedWaitpointData({ projectId, environmentId, anchorRunId, + mintKind, }); } + /** + * Create the RUN waitpoint that `buildRunAssociatedWaitpoint` minted. + * + * Only the store path calls this: the legacy path writes the row inside the run's own + * create. A crash between the run commit and this call leaves the waitpoint absent, and + * the parent's register step then fails loud rather than resuming without it. + */ + public async createRunAssociatedWaitpoint(params: { + runId: string; + data: AssociatedWaitpointData; + }): Promise { + return this.coordinator.createAssociatedWaitpoint(params); + } + /** * The record set for one resume, or undefined when no blocking waitpoint carries a store-format * id. diff --git a/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts b/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts index c965fe85246..c6f0aae3b38 100644 --- a/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts +++ b/internal-packages/run-engine/src/engine/tests/batchTriggerAndWait.test.ts @@ -4,7 +4,13 @@ import { } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect, describe } from "vitest"; -import { RunEngine } from "../index.js"; +import { + createTestEngine, + freshRunFriendlyId, + readRunBlockEdgesForArm, + readWaitpointForArm, + type WaitpointArm, +} from "./helpers/engineFactory.js"; import { setTimeout } from "node:timers/promises"; import { generateFriendlyId, BatchId } from "@trigger.dev/core/v3/isomorphic"; import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; @@ -12,12 +18,13 @@ import type { CompleteBatchResult, BatchItem } from "../../batch-queue/types.js" vi.setConfig({ testTimeout: 60_000 }); -describe("RunEngine batchTriggerAndWait", () => { +describe.each(["legacy", "store"])("RunEngine batchTriggerAndWait (%s)", (arm) => { containerTest("batchTriggerAndWait (no idempotency)", async ({ prisma, redisOptions }) => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -67,7 +74,7 @@ describe("RunEngine batchTriggerAndWait", () => { const parentRun = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -115,7 +122,7 @@ describe("RunEngine batchTriggerAndWait", () => { const child1 = await engine.trigger( { number: 1, - friendlyId: "run_c1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", @@ -142,7 +149,7 @@ describe("RunEngine batchTriggerAndWait", () => { const child2 = await engine.trigger( { number: 2, - friendlyId: "run_c12345", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", @@ -167,16 +174,11 @@ describe("RunEngine batchTriggerAndWait", () => { expect(parentAfterChild2.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check the waitpoint blocking the parent run - const runWaitpoints = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - orderBy: { - createdAt: "asc", - }, + const runWaitpoints = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(runWaitpoints.length).toBe(3); const child1Waitpoint = runWaitpoints.find( @@ -230,10 +232,11 @@ describe("RunEngine batchTriggerAndWait", () => { assertNonNullable(childExecutionDataAfter); expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - const child1WaitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: child1Waitpoint?.waitpointId, - }, + const child1WaitpointAfter = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: child1Waitpoint!.waitpointId, }); expect(child1WaitpointAfter?.completedAt).not.toBeNull(); expect(child1WaitpointAfter?.status).toBe("COMPLETED"); @@ -241,13 +244,11 @@ describe("RunEngine batchTriggerAndWait", () => { await setTimeout(500); - const runWaitpointsAfterFirstChild = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, + const runWaitpointsAfterFirstChild = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(runWaitpointsAfterFirstChild.length).toBe(3); @@ -291,10 +292,11 @@ describe("RunEngine batchTriggerAndWait", () => { assertNonNullable(child2ExecutionDataAfter); expect(child2ExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - const child2WaitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: child2Waitpoint?.waitpointId, - }, + const child2WaitpointAfter = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: child2Waitpoint!.waitpointId, }); expect(child2WaitpointAfter?.completedAt).not.toBeNull(); expect(child2WaitpointAfter?.status).toBe("COMPLETED"); @@ -302,13 +304,11 @@ describe("RunEngine batchTriggerAndWait", () => { await setTimeout(1_000); - const runWaitpointsAfterSecondChild = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, + const runWaitpointsAfterSecondChild = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(runWaitpointsAfterSecondChild.length).toBe(0); @@ -366,7 +366,8 @@ describe("RunEngine batchTriggerAndWait", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -421,7 +422,7 @@ describe("RunEngine batchTriggerAndWait", () => { const parentRun = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -471,7 +472,7 @@ describe("RunEngine batchTriggerAndWait", () => { const batchChild = await engine.trigger( { number: 1, - friendlyId: "run_c1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: batchChildTask, payload: "{}", @@ -524,13 +525,11 @@ describe("RunEngine batchTriggerAndWait", () => { await setTimeout(500); - const runWaitpointsAfterBatchChild = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, + const runWaitpointsAfterBatchChild = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(runWaitpointsAfterBatchChild.length).toBe(0); @@ -549,7 +548,7 @@ describe("RunEngine batchTriggerAndWait", () => { const _triggerAndWaitChildRun = await engine.trigger( { number: 1, - friendlyId: "run_c123456", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: triggerAndWaitChildTask, payload: "{}", @@ -587,7 +586,8 @@ describe("RunEngine batchTriggerAndWait", () => { // Create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -713,7 +713,7 @@ describe("RunEngine batchTriggerAndWait", () => { const parentRun = await engine.trigger( { number: 1, - friendlyId: "run_parent", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -847,8 +847,11 @@ describe("RunEngine batchTriggerAndWait", () => { // Wait for parent to be unblocked (use waitFor since tryCompleteBatch runs as background job) await vi.waitFor( async () => { - const waitpoints = await prisma.taskRunWaitpoint.findMany({ - where: { taskRunId: parentRun.id }, + const waitpoints = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(waitpoints.length).toBe(0); }, @@ -883,7 +886,8 @@ describe("RunEngine batchTriggerAndWait", () => { // Create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -1190,8 +1194,11 @@ describe("RunEngine batchTriggerAndWait", () => { // Wait for parent to be unblocked (use waitFor since tryCompleteBatch runs as background job) await vi.waitFor( async () => { - const waitpoints = await prisma.taskRunWaitpoint.findMany({ - where: { taskRunId: parentRun.id }, + const waitpoints = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: parentRun.id, }); expect(waitpoints.length).toBe(0); }, diff --git a/internal-packages/run-engine/src/engine/tests/helpers/engineFactory.ts b/internal-packages/run-engine/src/engine/tests/helpers/engineFactory.ts new file mode 100644 index 00000000000..299f3d45da7 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/helpers/engineFactory.ts @@ -0,0 +1,142 @@ +import type { RedisOptions } from "@internal/redis"; +import type { PrismaClient, Waitpoint } from "@trigger.dev/database"; +import { WaitpointStoreCoordinator } from "../../waitpointCoordinator/storeCoordinator.js"; +import { toPrismaWaitpoint } from "../../waitpointCoordinator/waitpointShape.js"; +import { generateRunOpsId, parseWaitpointId, RunId } from "@trigger.dev/core/v3/isomorphic"; +import { RunEngine } from "../../index.js"; +import type { RunEngineOptions } from "../../types.js"; + +/** Which waitpoint coordinator the engine under test routes through. */ +export type WaitpointArm = "legacy" | "store"; + +export type CreateTestEngineOptions = Omit & { + /** Defaults to legacy, which is the behaviour every pre-existing test expects. */ + waitpointArm?: WaitpointArm; + /** Redis for the store arm. Defaults to the run lock's, which is what tests already pass. */ + waitpointStoreRedis?: RedisOptions; +}; + +/** + * Build a RunEngine for a test, with the waitpoint arm selectable. + * + * The arm is a constructor concern rather than a per-call one: an engine with no store + * configured cannot reach the store path at all, which is what an unflipped deployment + * looks like. Tests that also want to exercise the mint flag pass `waitpointMintKind` at + * the call site, as production does. + */ +export function createTestEngine(options: CreateTestEngineOptions): RunEngine { + const { waitpointArm = "legacy", waitpointStoreRedis, ...engineOptions } = options; + + return new RunEngine({ + ...engineOptions, + waitpointStore: + waitpointArm === "store" + ? { redis: waitpointStoreRedis ?? engineOptions.runLock.redis } + : undefined, + }); +} + +/** + * A run friendly id whose shape suits the arm. + * + * A store RUN or BATCH waitpoint derives its id from the anchor's id body, so a store-arm + * test that triggers with a legacy id mints a LEGACY waitpoint, passes every assertion, + * and proves nothing. Use this rather than a literal. + */ +export function freshRunFriendlyId(arm: WaitpointArm): string { + return arm === "store" ? RunId.toFriendlyId(generateRunOpsId()) : RunId.generate().friendlyId; +} + +/** + * Assert a store-arm test actually minted into the store. + * + * The failure this catches is a test that runs green on both arms while the store arm + * quietly did nothing, which is the most plausible way for this migration to look + * finished and not be. Call it where a test is expected to mint. + */ +export function assertStoreResident(waitpointId: string): void { + if (parseWaitpointId(waitpointId).format !== "b32hexW") { + throw new Error( + `expected ${waitpointId} to be store resident; a store-arm test that mints a legacy ` + + `waitpoint asserts nothing about the store path` + ); + } +} + +type ArmRead = { arm: WaitpointArm; prisma: PrismaClient; redisOptions: RedisOptions }; + +/** + * Read a waitpoint from whichever system holds it. + * + * A test that reads `prisma.waitpoint` directly is asserting against Postgres, and the + * store path writes no row there for RUN, BATCH or DATETIME. Going through here lets one + * expectation hold on both arms. + */ +export async function readWaitpointForArm( + args: ArmRead & { waitpointId: string } +): Promise { + if (parseWaitpointId(args.waitpointId).format === "legacy") { + return args.prisma.waitpoint.findFirst({ where: { id: args.waitpointId } }); + } + + const store = new WaitpointStoreCoordinator({ redisOptions: args.redisOptions }); + try { + const held = await store.readWaitpoint(args.waitpointId); + return held ? toPrismaWaitpoint(held.record, held.status, held.completion) : null; + } finally { + await store.quit(); + } +} + +export type ArmBlockEdge = { + waitpointId: string; + batchId: string | null; + batchIndex: number | null; + waitpoint: Waitpoint; +}; + +/** + * A run's blocking edges, from both systems. + * + * Always unions the two rather than switching on the arm, because a run can hold one edge + * in each at the same time and a test that saw only half would report the wrong count. + */ +export async function readRunBlockEdgesForArm( + args: ArmRead & { runId: string } +): Promise { + const legacy = await args.prisma.taskRunWaitpoint.findMany({ + where: { taskRunId: args.runId }, + include: { waitpoint: true }, + }); + + const edges: ArmBlockEdge[] = legacy.map((edge) => ({ + waitpointId: edge.waitpointId, + batchId: edge.batchId, + batchIndex: edge.batchIndex, + waitpoint: edge.waitpoint, + })); + + if (args.arm !== "store") { + return edges; + } + + const store = new WaitpointStoreCoordinator({ redisOptions: args.redisOptions }); + try { + const state = await store.readBlockState(args.runId); + for (const edge of state.edges) { + const held = await store.readWaitpoint(edge.waitpointId); + if (held) { + edges.push({ + waitpointId: edge.waitpointId, + batchId: edge.batchId ?? null, + batchIndex: edge.batchIndex ?? null, + waitpoint: toPrismaWaitpoint(held.record, held.status, held.completion), + }); + } + } + } finally { + await store.quit(); + } + + return edges; +} diff --git a/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts b/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts index d45e3bcd6cb..7584cca6dfb 100644 --- a/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts +++ b/internal-packages/run-engine/src/engine/tests/lazyWaitpoint.test.ts @@ -1,7 +1,7 @@ import { containerTest, assertNonNullable } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect } from "vitest"; -import { RunEngine } from "../index.js"; +import { createTestEngine } from "./helpers/engineFactory.js"; import { setTimeout } from "node:timers/promises"; import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; @@ -13,7 +13,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -90,7 +90,7 @@ describe("RunEngine lazy waitpoint creation", () => { containerTest("Waitpoint created for triggerAndWait", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -199,7 +199,7 @@ describe("RunEngine lazy waitpoint creation", () => { containerTest("Completion without waitpoint succeeds", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -301,7 +301,7 @@ describe("RunEngine lazy waitpoint creation", () => { containerTest("Cancellation without waitpoint succeeds", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -386,7 +386,7 @@ describe("RunEngine lazy waitpoint creation", () => { containerTest("TTL expiration without waitpoint succeeds", async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -485,7 +485,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -606,7 +606,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -701,7 +701,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -818,7 +818,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -930,7 +930,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -1029,7 +1029,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, @@ -1190,7 +1190,7 @@ describe("RunEngine lazy waitpoint creation", () => { async ({ prisma, redisOptions }) => { const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, diff --git a/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts b/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts index ad4c32c7ba7..c7e86e1806b 100644 --- a/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts +++ b/internal-packages/run-engine/src/engine/tests/triggerAndWait.test.ts @@ -1,19 +1,26 @@ import { assertNonNullable, containerTest } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect } from "vitest"; -import { RunEngine } from "../index.js"; +import { + createTestEngine, + freshRunFriendlyId, + readRunBlockEdgesForArm, + readWaitpointForArm, + type WaitpointArm, +} from "./helpers/engineFactory.js"; import { setTimeout } from "node:timers/promises"; import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; import { RunDuplicateIdempotencyKeyError } from "../errors.js"; vi.setConfig({ testTimeout: 60_000 }); -describe("RunEngine triggerAndWait", () => { +describe.each(["legacy", "store"])("RunEngine triggerAndWait (%s)", (arm) => { containerTest("triggerAndWait", async ({ prisma, redisOptions }) => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -55,7 +62,7 @@ describe("RunEngine triggerAndWait", () => { const parentRun = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -90,7 +97,7 @@ describe("RunEngine triggerAndWait", () => { const childRun = await engine.trigger( { number: 1, - friendlyId: "run_c1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", @@ -118,14 +125,9 @@ describe("RunEngine triggerAndWait", () => { expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check the waitpoint blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: parentRun.id }))[0] ?? + null; assertNonNullable(runWaitpoint); expect(runWaitpoint.waitpoint.type).toBe("RUN"); expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); @@ -160,10 +162,11 @@ describe("RunEngine triggerAndWait", () => { assertNonNullable(childExecutionDataAfter); expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - const waitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: runWaitpoint.waitpointId, - }, + const waitpointAfter = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: runWaitpoint.waitpointId!, }); expect(waitpointAfter?.completedAt).not.toBeNull(); expect(waitpointAfter?.status).toBe("COMPLETED"); @@ -171,14 +174,9 @@ describe("RunEngine triggerAndWait", () => { await setTimeout(500); - const runWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpointAfter = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: parentRun.id }))[0] ?? + null; expect(runWaitpointAfter).toBeNull(); //parent snapshot @@ -203,7 +201,8 @@ describe("RunEngine triggerAndWait", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -245,7 +244,7 @@ describe("RunEngine triggerAndWait", () => { const parentRun1 = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -277,7 +276,7 @@ describe("RunEngine triggerAndWait", () => { const childRun = await engine.trigger( { number: 1, - friendlyId: "run_c1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", @@ -305,14 +304,9 @@ describe("RunEngine triggerAndWait", () => { expect(parentExecutionData.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check the waitpoint blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun1.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: parentRun1.id }))[0] ?? + null; assertNonNullable(runWaitpoint); expect(runWaitpoint.waitpoint.type).toBe("RUN"); expect(runWaitpoint.waitpoint.completedByTaskRunId).toBe(childRun.id); @@ -334,7 +328,7 @@ describe("RunEngine triggerAndWait", () => { const parentRun2 = await engine.trigger( { number: 2, - friendlyId: "run_p1235", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -399,10 +393,11 @@ describe("RunEngine triggerAndWait", () => { assertNonNullable(childExecutionDataAfter); expect(childExecutionDataAfter.snapshot.executionStatus).toBe("FINISHED"); - const waitpointAfter = await prisma.waitpoint.findFirst({ - where: { - id: runWaitpoint.waitpointId, - }, + const waitpointAfter = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: runWaitpoint.waitpointId!, }); expect(waitpointAfter?.completedAt).not.toBeNull(); expect(waitpointAfter?.status).toBe("COMPLETED"); @@ -410,18 +405,14 @@ describe("RunEngine triggerAndWait", () => { await setTimeout(500); - const parent1RunWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun1.id, - }, - }); + const parent1RunWaitpointAfter = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: parentRun1.id }))[0] ?? + null; expect(parent1RunWaitpointAfter).toBeNull(); - const parent2RunWaitpointAfter = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: parentRun2.id, - }, - }); + const parent2RunWaitpointAfter = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: parentRun2.id }))[0] ?? + null; expect(parent2RunWaitpointAfter).toBeNull(); //parent snapshot @@ -460,7 +451,8 @@ describe("RunEngine triggerAndWait", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -503,7 +495,7 @@ describe("RunEngine triggerAndWait", () => { const parentRun1 = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -534,7 +526,7 @@ describe("RunEngine triggerAndWait", () => { const parentRun2 = await engine.trigger( { number: 2, - friendlyId: "run_p12345", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: parentTask, payload: "{}", @@ -564,7 +556,7 @@ describe("RunEngine triggerAndWait", () => { await engine.trigger( { number: 1, - friendlyId: "run_c1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", @@ -589,7 +581,7 @@ describe("RunEngine triggerAndWait", () => { engine.trigger( { number: 2, - friendlyId: "run_c12345", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier: childTask, payload: "{}", diff --git a/internal-packages/run-engine/src/engine/tests/waitpointBatchCreate.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointBatchCreate.test.ts new file mode 100644 index 00000000000..c4a16d1508e --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/waitpointBatchCreate.test.ts @@ -0,0 +1,193 @@ +import type { RedisOptions } from "@internal/redis"; +import { assertNonNullable, containerTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { BatchId, generateRunOpsId, parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect } from "vitest"; +import { RunEngine } from "../index.js"; +import { freshRunFriendlyId, type WaitpointArm } from "./helpers/engineFactory.js"; +import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +function engineFor(arm: WaitpointArm, prisma: PrismaClient, redisOptions: RedisOptions) { + return new RunEngine({ + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + waitpointStore: arm === "store" ? { redis: redisOptions } : undefined, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); +} + +function triggerParams(friendlyId: string, environment: any, taskIdentifier: string) { + return { + number: 1, + friendlyId, + environment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + workerQueue: "main", + queue: `task/${taskIdentifier}`, + isTest: false, + tags: [], + }; +} + +async function seedBatch(prisma: PrismaClient, environment: any, arm: WaitpointArm) { + // Mirrors batchIdForMintKind: a run-ops batch carries a run-ops ROW id, and the BATCH + // waitpoint derives from that id, not from the friendly id. + const { id, friendlyId } = + arm === "store" + ? (() => { + const core = generateRunOpsId(); + return { id: core, friendlyId: BatchId.toFriendlyId(core) }; + })() + : BatchId.generate(); + + return prisma.batchTaskRun.create({ + data: { id, friendlyId, runtimeEnvironmentId: environment.id, runCount: 1 }, + }); +} + +describe.each(["legacy", "store"])("BATCH waitpoint create (%s arm)", (arm) => { + containerTest( + "blockRunWithCreatedBatch suspends the parent", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId(arm), environment, taskIdentifier), + prisma + ); + const batch = await seedBatch(prisma, environment, arm); + + const waitpoint = await engine.blockRunWithCreatedBatch({ + runId: parent.id, + batchId: batch.id, + environmentId: environment.id, + projectId: environment.project.id, + organizationId: environment.organization.id, + waitpointMintKind: arm, + }); + + assertNonNullable(waitpoint); + expect(waitpoint.type).toBe("BATCH"); + expect(waitpoint.completedByBatchId).toBe(batch.id); + expect(parseWaitpointId(waitpoint.id).format).toBe(arm === "store" ? "b32hexW" : "legacy"); + + // A parent that was never blocked stays QUEUED. + const snapshot = await engine.getRunExecutionData({ runId: parent.id }); + assertNonNullable(snapshot); + expect(snapshot.snapshot.executionStatus).toBe("SUSPENDED"); + } finally { + await engine.quit(); + } + } + ); + + containerTest("a duplicate batch returns null", async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId(arm), environment, taskIdentifier), + prisma + ); + const batch = await seedBatch(prisma, environment, arm); + const args = { + runId: parent.id, + batchId: batch.id, + environmentId: environment.id, + projectId: environment.project.id, + organizationId: environment.organization.id, + waitpointMintKind: arm, + } as const; + + expect(await engine.blockRunWithCreatedBatch(args)).not.toBeNull(); + // The legacy arm reports this through a unique-index violation, the store arm + // through its create-if-absent. Same contract either way. + expect(await engine.blockRunWithCreatedBatch(args)).toBeNull(); + } finally { + await engine.quit(); + } + }); +}); + +describe("BATCH waitpoint, the lockless absorb guard", () => { + // The invariant: the parent's BATCH waitpoint holds the pending set open for the whole + // absorb, so a completion arriving mid-absorb can never see an empty set and resume the + // parent before its items are registered. + containerTest( + "keeps the parent BATCH waitpoint pending while items absorb", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("store", prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId("store"), environment, taskIdentifier), + prisma + ); + const batch = await seedBatch(prisma, environment, "store"); + + const batchWaitpoint = await engine.blockRunWithCreatedBatch({ + runId: parent.id, + batchId: batch.id, + environmentId: environment.id, + projectId: environment.project.id, + organizationId: environment.organization.id, + waitpointMintKind: "store", + }); + assertNonNullable(batchWaitpoint); + + for (let index = 0; index < 3; index++) { + await engine.trigger( + { + ...triggerParams(freshRunFriendlyId("store"), environment, taskIdentifier), + parentTaskRunId: parent.id, + rootTaskRunId: parent.id, + resumeParentOnCompletion: true, + depth: 1, + batch: { id: batch.id, index }, + waitpointMintKind: "store", + }, + prisma + ); + + // After every item, the parent is still blocked by its BATCH waitpoint. + const snapshot = await engine.getRunExecutionData({ runId: parent.id }); + assertNonNullable(snapshot); + expect(snapshot.snapshot.executionStatus).toBe("SUSPENDED"); + } + } finally { + await engine.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpointMixedMode.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointMixedMode.test.ts new file mode 100644 index 00000000000..e78ee4b0c98 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/waitpointMixedMode.test.ts @@ -0,0 +1,186 @@ +import type { RedisOptions } from "@internal/redis"; +import { assertNonNullable, containerTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect } from "vitest"; +import type { RunEngine } from "../index.js"; +import { createTestEngine, freshRunFriendlyId } from "./helpers/engineFactory.js"; +import { setTimeout } from "node:timers/promises"; +import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +/** Both arms live in one engine, which is what a flipped organization actually runs. */ +function mixedEngine(prisma: PrismaClient, redisOptions: RedisOptions) { + return createTestEngine({ + waitpointArm: "store", + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); +} + +function triggerParams(friendlyId: string, environment: any, taskIdentifier: string) { + return { + number: 1, + friendlyId, + environment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + workerQueue: "main", + queue: `task/${taskIdentifier}`, + isTest: false, + tags: [], + }; +} + +/** One legacy waitpoint and one store waitpoint, both blocking the same run. */ +async function blockOnBoth(engine: RunEngine, prisma: PrismaClient, environment: any) { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const run = await engine.trigger( + triggerParams(freshRunFriendlyId("store"), environment, taskIdentifier), + prisma + ); + + // The run has to be executing before it can be suspended and resumed. A run that never + // started has no attempt to continue, so a resume assertion on it would be meaningless. + const dequeued = await engine.dequeueFromWorkerQueue({ + consumerId: "test_12345", + workerQueue: "main", + }); + await engine.startRunAttempt({ + runId: dequeued[0]!.run.id, + snapshotId: dequeued[0]!.snapshot.id, + }); + + const legacy = await engine.createManualWaitpoint({ + waitpointMintKind: "legacy", + environmentId: environment.id, + projectId: environment.project.id, + }); + const store = await engine.createManualWaitpoint({ + waitpointMintKind: "store", + environmentId: environment.id, + projectId: environment.project.id, + }); + + expect(parseWaitpointId(legacy.waitpoint.id).format).toBe("legacy"); + expect(parseWaitpointId(store.waitpoint.id).format).toBe("b32hexW"); + + await engine.blockRunWithWaitpoint({ + runId: run.id, + waitpoints: [legacy.waitpoint.id, store.waitpoint.id], + projectId: environment.project.id, + organizationId: environment.organization.id, + }); + + const blocked = await engine.getRunExecutionData({ runId: run.id }); + assertNonNullable(blocked); + expect(blocked.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); + + return { run, legacyId: legacy.waitpoint.id, storeId: store.waitpoint.id }; +} + +async function statusOf(engine: RunEngine, runId: string) { + const data = await engine.getRunExecutionData({ runId }); + assertNonNullable(data); + return data.snapshot.executionStatus; +} + +/** + * The resume runs asynchronously off the completion, so "still blocked" has to be given + * time to be wrong. Settling first means a passing assertion is evidence the run stayed + * put, not evidence the resume had not happened yet. + */ +async function staysBlocked(engine: RunEngine, runId: string) { + await setTimeout(1_000); + expect(await statusOf(engine, runId)).toBe("EXECUTING_WITH_WAITPOINTS"); +} + +async function resumes(engine: RunEngine, runId: string) { + await vi.waitFor(async () => expect(await statusOf(engine, runId)).toBe("EXECUTING"), { + timeout: 10_000, + interval: 100, + }); +} + +describe("a run blocked by one waitpoint of each kind", () => { + // The dual pending check: neither arm can see the other's waitpoint, so a resume decided + // from one arm alone would release the run while the other half is still outstanding. + containerTest( + "stays blocked until both complete, legacy first", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = mixedEngine(prisma, redisOptions); + + try { + const { run, legacyId, storeId } = await blockOnBoth(engine, prisma, environment); + + await engine.completeWaitpoint({ id: legacyId }); + await staysBlocked(engine, run.id); + + await engine.completeWaitpoint({ id: storeId }); + await resumes(engine, run.id); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "stays blocked until both complete, store first", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = mixedEngine(prisma, redisOptions); + + try { + const { run, legacyId, storeId } = await blockOnBoth(engine, prisma, environment); + + await engine.completeWaitpoint({ id: storeId }); + await staysBlocked(engine, run.id); + + await engine.completeWaitpoint({ id: legacyId }); + await resumes(engine, run.id); + } finally { + await engine.quit(); + } + } + ); + + // Clearing has the same trap in reverse: an empty partition must not be read as "clear + // everything", or resuming a mixed run would wipe the other arm's edges. + containerTest("clears both arms' edges on resume", async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = mixedEngine(prisma, redisOptions); + + try { + const { run, legacyId, storeId } = await blockOnBoth(engine, prisma, environment); + + await engine.completeWaitpoint({ id: legacyId }); + await engine.completeWaitpoint({ id: storeId }); + await resumes(engine, run.id); + + const legacyEdges = await prisma.taskRunWaitpoint.count({ where: { taskRunId: run.id } }); + expect(legacyEdges).toBe(0); + } finally { + await engine.quit(); + } + }); +}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpointPublicRouter.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointPublicRouter.test.ts index 5011249dc5c..a94240258b1 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpointPublicRouter.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpointPublicRouter.test.ts @@ -4,7 +4,8 @@ import { PostgresRunStore } from "@internal/run-store"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import { expect } from "vitest"; import { setTimeout } from "node:timers/promises"; -import { RunEngine } from "../index.js"; +import type { RunEngine } from "../index.js"; +import { createTestEngine } from "./helpers/engineFactory.js"; import type { CrossSeamGuardHook } from "../types.js"; import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; @@ -130,7 +131,7 @@ describe("RunEngine public waitpoint router", () => { prisma, readOnlyPrisma: prisma, }); - const engine = new RunEngine(engineOptions(redisOptions, prisma, { store })); + const engine = createTestEngine(engineOptions(redisOptions, prisma, { store })); try { const { waitpoint } = await engine.createManualWaitpoint({ @@ -189,7 +190,7 @@ describe("RunEngine public waitpoint router", () => { prisma, readOnlyPrisma: prisma, }); - const engine = new RunEngine(engineOptions(redisOptions, prisma, { store })); + const engine = createTestEngine(engineOptions(redisOptions, prisma, { store })); try { await setupBackgroundWorker(engine, environment, "test-task"); @@ -247,7 +248,7 @@ describe("RunEngine public waitpoint router", () => { prisma, readOnlyPrisma: prisma, }); - const engine = new RunEngine(engineOptions(redisOptions, prisma, { store })); + const engine = createTestEngine(engineOptions(redisOptions, prisma, { store })); try { await setupBackgroundWorker(engine, environment, "test-task"); @@ -281,7 +282,7 @@ describe("RunEngine public waitpoint router", () => { "delegators (create/block/getOrCreate) work through the public API", async ({ prisma, redisOptions }) => { const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine(engineOptions(redisOptions, prisma)); + const engine = createTestEngine(engineOptions(redisOptions, prisma)); try { await setupBackgroundWorker(engine, environment, "test-task"); @@ -338,7 +339,7 @@ describe("RunEngine public waitpoint router", () => { const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); const seen: Array<{ waitpointId: string; routeKind: string }> = []; - const engine = new RunEngine( + const engine = createTestEngine( engineOptions(redisOptions, prisma, { crossSeamGuard: async ({ waitpointId, routeKind }) => { seen.push({ waitpointId, routeKind }); @@ -375,7 +376,7 @@ describe("RunEngine public waitpoint router", () => { "completeWaitpoint with a throwing guard does not apply (loud, no silent local apply)", async ({ prisma, redisOptions }) => { const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine( + const engine = createTestEngine( engineOptions(redisOptions, prisma, { crossSeamGuard: async () => { throw new Error("UnclassifiableRunId"); @@ -407,7 +408,7 @@ describe("RunEngine public waitpoint router", () => { async ({ prisma, redisOptions }) => { const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); // default single PostgresRunStore (no injected store), no crossSeamGuard - const engine = new RunEngine(engineOptions(redisOptions, prisma)); + const engine = createTestEngine(engineOptions(redisOptions, prisma)); try { await setupBackgroundWorker(engine, environment, "test-task"); @@ -463,7 +464,7 @@ describe("RunEngine public waitpoint router", () => { "FK-drop app-integrity: routed waitpoint round-trip is well-formed and FK-independent", async ({ prisma, redisOptions }) => { const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine(engineOptions(redisOptions, prisma)); + const engine = createTestEngine(engineOptions(redisOptions, prisma)); try { await setupBackgroundWorker(engine, environment, "test-task"); diff --git a/internal-packages/run-engine/src/engine/tests/waitpointRace.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointRace.test.ts index f1e8c580068..611bfd72656 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpointRace.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpointRace.test.ts @@ -1,7 +1,7 @@ import { containerTest } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect } from "vitest"; -import { RunEngine } from "../index.js"; +import { createTestEngine } from "./helpers/engineFactory.js"; import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; import { setTimeout } from "timers/promises"; @@ -12,7 +12,7 @@ describe("RunEngine Waitpoints – race condition", () => { "join-row removed before run continues (failing race)", async ({ prisma, redisOptions }) => { const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ prisma, worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, queue: { diff --git a/internal-packages/run-engine/src/engine/tests/waitpointRunCreate.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointRunCreate.test.ts new file mode 100644 index 00000000000..a6eecf7ff2c --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/waitpointRunCreate.test.ts @@ -0,0 +1,231 @@ +import { createRedisClient, type RedisOptions } from "@internal/redis"; +import { assertNonNullable, containerTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { + parseWaitpointId, + RunId, + deriveWaitpointIdFromAnchor, +} from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect } from "vitest"; +import { RunEngine } from "../index.js"; +import { + assertStoreResident, + freshRunFriendlyId, + type WaitpointArm, +} from "./helpers/engineFactory.js"; +import { waitpointKeys } from "../waitpointCoordinator/keys.js"; +import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +function engineFor(arm: WaitpointArm, prisma: PrismaClient, redisOptions: RedisOptions) { + return new RunEngine({ + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + waitpointStore: arm === "store" ? { redis: redisOptions } : undefined, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); +} + +function triggerParams(friendlyId: string, environment: any, taskIdentifier: string) { + return { + number: 1, + friendlyId, + environment, + taskIdentifier, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: "t12345", + spanId: "s12345", + workerQueue: "main", + queue: `task/${taskIdentifier}`, + isTest: false, + tags: [], + }; +} + +describe.each(["legacy", "store"])("trigger-time RUN waitpoint (%s arm)", (arm) => { + containerTest("triggerAndWait suspends the parent", async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId(arm), environment, taskIdentifier), + prisma + ); + + await engine.trigger( + { + ...triggerParams(freshRunFriendlyId(arm), environment, taskIdentifier), + parentTaskRunId: parent.id, + rootTaskRunId: parent.id, + resumeParentOnCompletion: true, + depth: 1, + waitpointMintKind: arm, + }, + prisma + ); + + // A parent that was never blocked stays QUEUED, so QUEUED must NOT be acceptable + // here. This is the assertion that catches the block step being skipped entirely. + const snapshot = await engine.getRunExecutionData({ runId: parent.id }); + assertNonNullable(snapshot); + expect(snapshot.snapshot.executionStatus).toBe("SUSPENDED"); + } finally { + await engine.quit(); + } + }); +}); + +describe("trigger-time RUN waitpoint, store specifics", () => { + containerTest( + "derives the waitpoint id from the child run's id", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("store", prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId("store"), environment, taskIdentifier), + prisma + ); + const childFriendlyId = freshRunFriendlyId("store"); + const child = await engine.trigger( + { + ...triggerParams(childFriendlyId, environment, taskIdentifier), + parentTaskRunId: parent.id, + rootTaskRunId: parent.id, + resumeParentOnCompletion: true, + depth: 1, + waitpointMintKind: "store", + }, + prisma + ); + + const expected = deriveWaitpointIdFromAnchor(child.id, "RUN"); + assertNonNullable(expected); + // Guards the vacuous pass: a store-arm test whose waitpoint minted legacy would + // satisfy everything below while proving nothing about the store. + assertStoreResident(expected); + + // No Postgres row: the store owns this waitpoint entirely. + const row = await prisma.waitpoint.findFirst({ where: { id: expected } }); + expect(row).toBeNull(); + } finally { + await engine.quit(); + } + } + ); + + // The Frozen-list rule: a crash between the run commit and the waitpoint create must fail + // loud at the parent's register step, never resume the parent as though nothing was owed. + containerTest( + "fails loud when the store waitpoint is missing at register", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("store", prisma, redisOptions); + const redis = createRedisClient(redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId("store"), environment, taskIdentifier), + prisma + ); + + const childFriendlyId = freshRunFriendlyId("store"); + const childId = RunId.fromFriendlyId(childFriendlyId); + const waitpointId = deriveWaitpointIdFromAnchor(childId, "RUN"); + assertNonNullable(waitpointId); + + // Stand in for the crash: the run commits, the waitpoint never reaches the store. + // Deleting the record before the parent registers reproduces that window exactly. + const failing = engine + .trigger( + { + ...triggerParams(childFriendlyId, environment, taskIdentifier), + parentTaskRunId: parent.id, + rootTaskRunId: parent.id, + resumeParentOnCompletion: true, + depth: 1, + waitpointMintKind: "store", + }, + prisma + ) + .then(async (run) => { + await redis.del(waitpointKeys(waitpointId).record); + await engine.blockRunWithWaitpoint({ + runId: parent.id, + waitpoints: waitpointId, + projectId: environment.project.id, + organizationId: environment.organization.id, + }); + return run; + }); + + await expect(failing).rejects.toThrow(); + } finally { + await redis.quit(); + await engine.quit(); + } + } + ); + + // Coexistence: a flipped organization still on legacy run ids keeps legacy waitpoints, + // because the derivation needs a run-ops anchor to work from. + containerTest( + "keeps a legacy waitpoint when the run id is legacy shaped", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("store", prisma, redisOptions); + + try { + const taskIdentifier = "test-task"; + await setupBackgroundWorker(engine, environment, taskIdentifier); + + const parent = await engine.trigger( + triggerParams(freshRunFriendlyId("legacy"), environment, taskIdentifier), + prisma + ); + const child = await engine.trigger( + { + ...triggerParams(freshRunFriendlyId("legacy"), environment, taskIdentifier), + parentTaskRunId: parent.id, + rootTaskRunId: parent.id, + resumeParentOnCompletion: true, + depth: 1, + waitpointMintKind: "store", + }, + prisma + ); + + const row = await prisma.waitpoint.findFirst({ where: { completedByTaskRunId: child.id } }); + assertNonNullable(row); + expect(parseWaitpointId(row.id).format).toBe("legacy"); + } finally { + await engine.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpointStandaloneCreates.test.ts b/internal-packages/run-engine/src/engine/tests/waitpointStandaloneCreates.test.ts new file mode 100644 index 00000000000..da4647c73a9 --- /dev/null +++ b/internal-packages/run-engine/src/engine/tests/waitpointStandaloneCreates.test.ts @@ -0,0 +1,154 @@ +import { containerTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import type { RedisOptions } from "@internal/redis"; +import { describe, expect } from "vitest"; +import { RunEngine } from "../index.js"; +import type { WaitpointArm } from "./helpers/engineFactory.js"; +import { setupAuthenticatedEnvironment } from "./setup.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +function engineFor(arm: WaitpointArm, prisma: PrismaClient, redisOptions: RedisOptions) { + return new RunEngine({ + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + // The arm under test is selected by whether a store is configured AT ALL, plus the mint + // kind each call passes. Both together are what a flipped organization looks like. + waitpointStore: arm === "store" ? { redis: redisOptions } : undefined, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0001, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); +} + +const expectedFormat: Record = { legacy: "legacy", store: "b32hexW" }; + +describe.each(["legacy", "store"])("standalone waitpoint creates (%s arm)", (arm) => { + containerTest( + "createManualWaitpoint mints into the expected system", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const { waitpoint } = await engine.createManualWaitpoint({ + environmentId: environment.id, + projectId: environment.project.id, + waitpointMintKind: arm, + }); + + expect(parseWaitpointId(waitpoint.id).format).toBe(expectedFormat[arm]); + expect(waitpoint.status).toBe("PENDING"); + expect(waitpoint.type).toBe("MANUAL"); + // Read unconditionally by the debounce path, so it must never be undefined. + expect(waitpoint.outputIsError).toBe(false); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "a repeated idempotency key returns the cached waitpoint", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const args = { + environmentId: environment.id, + projectId: environment.project.id, + idempotencyKey: "same-key", + waitpointMintKind: arm, + } as const; + + const first = await engine.createManualWaitpoint(args); + const second = await engine.createManualWaitpoint(args); + + expect(first.isCached).toBe(false); + expect(second.isCached).toBe(true); + expect(second.waitpoint.id).toBe(first.waitpoint.id); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "createDateTimeWaitpoint mints into the expected system", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor(arm, prisma, redisOptions); + + try { + const { waitpoint } = await engine.createDateTimeWaitpoint({ + environmentId: environment.id, + projectId: environment.project.id, + completedAfter: new Date(Date.now() + 60_000), + waitpointMintKind: arm, + }); + + expect(parseWaitpointId(waitpoint.id).format).toBe(expectedFormat[arm]); + expect(waitpoint.type).toBe("DATETIME"); + expect(waitpoint.completedAfter).not.toBeNull(); + } finally { + await engine.quit(); + } + } + ); +}); + +describe("standalone waitpoint creates, mint-kind fallback", () => { + // Reversibility: clearing the flag must revert the NEXT mint with no deploy, and an + // engine that has a store configured must still mint legacy when told to. + containerTest( + "a legacy mint stays legacy even with a store configured", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("store", prisma, redisOptions); + + try { + const { waitpoint } = await engine.createManualWaitpoint({ + environmentId: environment.id, + projectId: environment.project.id, + waitpointMintKind: "legacy", + }); + + expect(parseWaitpointId(waitpoint.id).format).toBe("legacy"); + } finally { + await engine.quit(); + } + } + ); + + // Fail safe, not fail loud: a store mint on a process with no store configured must not + // turn every trigger for a flipped organization into an error. + containerTest( + "a store mint falls back to legacy when no store is configured", + async ({ prisma, redisOptions }) => { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const engine = engineFor("legacy", prisma, redisOptions); + + try { + const { waitpoint } = await engine.createManualWaitpoint({ + environmentId: environment.id, + projectId: environment.project.id, + waitpointMintKind: "store", + }); + + expect(parseWaitpointId(waitpoint.id).format).toBe("legacy"); + } finally { + await engine.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts index 39cd9ba990a..ba079ef7c1f 100644 --- a/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts +++ b/internal-packages/run-engine/src/engine/tests/waitpoints.test.ts @@ -1,7 +1,13 @@ import { assertNonNullable, containerTest } from "@internal/testcontainers"; import { trace } from "@internal/tracing"; import { expect } from "vitest"; -import { RunEngine } from "../index.js"; +import { + createTestEngine, + freshRunFriendlyId, + readRunBlockEdgesForArm, + readWaitpointForArm, + type WaitpointArm, +} from "./helpers/engineFactory.js"; import { setTimeout } from "node:timers/promises"; import type { EventBusEventArgs } from "../eventBus.js"; import { isWaitpointOutputTimeout } from "@trigger.dev/core/v3"; @@ -9,12 +15,13 @@ import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "./setup.js vi.setConfig({ testTimeout: 60_000 }); -describe("RunEngine Waitpoints", () => { +describe.each(["legacy", "store"])("RunEngine Waitpoints (%s)", (arm) => { containerTest("waitForDuration", async ({ prisma, redisOptions }) => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -53,7 +60,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -89,6 +96,7 @@ describe("RunEngine Waitpoints", () => { //waitForDuration const date = new Date(Date.now() + durationMs); const { waitpoint } = await engine.createDateTimeWaitpoint({ + waitpointMintKind: arm, projectId: authenticatedEnvironment.project.id, environmentId: authenticatedEnvironment.id, completedAfter: date, @@ -118,10 +126,11 @@ describe("RunEngine Waitpoints", () => { { timeout: 10_000, interval: 100 } ); - const waitpoint2 = await prisma.waitpoint.findFirst({ - where: { - id: waitpoint.id, - }, + const waitpoint2 = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: waitpoint.id, }); expect(waitpoint2?.status).toBe("COMPLETED"); expect(waitpoint2?.completedAt?.getTime()).toBeLessThanOrEqual(date.getTime() + 200); @@ -137,7 +146,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -176,7 +186,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -210,6 +220,7 @@ describe("RunEngine Waitpoints", () => { //waitForDuration const date = new Date(Date.now() + 60_000); const { waitpoint } = await engine.createDateTimeWaitpoint({ + waitpointMintKind: arm, projectId: authenticatedEnvironment.project.id, environmentId: authenticatedEnvironment.id, completedAfter: date, @@ -259,14 +270,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData2.completedWaitpoints.length).toBe(0); //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); } finally { await engine.quit(); @@ -279,7 +284,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -318,7 +324,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -351,6 +357,7 @@ describe("RunEngine Waitpoints", () => { //create a manual waitpoint const result = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, }); @@ -368,14 +375,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check there is a waitpoint blocking the parent run - const runWaitpointBefore = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpointBefore = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpointBefore?.waitpointId).toBe(result.waitpoint.id); let event: EventBusEventArgs<"workerNotification">[0] | undefined = undefined; @@ -398,14 +399,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData2?.snapshot.executionStatus).toBe("EXECUTING"); //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); } finally { await engine.quit(); @@ -417,7 +412,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -456,7 +452,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -489,6 +485,7 @@ describe("RunEngine Waitpoints", () => { //create a manual waitpoint const result = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, //fail after 200ms @@ -517,18 +514,18 @@ describe("RunEngine Waitpoints", () => { const executionData2 = await engine.getRunExecutionData({ runId: run.id }); expect(executionData2?.snapshot.executionStatus).toBe("EXECUTING"); - expect(executionData2?.completedWaitpoints.length).toBe(1); - expect(executionData2?.completedWaitpoints[0].outputIsError).toBe(true); + // Executor-visible completed waitpoints are hydrated from the snapshot entry's record + // set for a store-resident waitpoint, and the hook that reads it back belongs to the + // snapshot lane rather than here. Until that lands, this assertion can only hold on + // the Postgres arm. Everything else in this case runs on both. + if (arm === "legacy") { + expect(executionData2?.completedWaitpoints.length).toBe(1); + expect(executionData2?.completedWaitpoints[0].outputIsError).toBe(true); + } //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); } finally { await engine.quit(); @@ -541,7 +538,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -580,7 +578,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -620,6 +618,7 @@ describe("RunEngine Waitpoints", () => { const results = await Promise.all( Array.from({ length: waitpointCount }).map(() => engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, }) @@ -642,13 +641,11 @@ describe("RunEngine Waitpoints", () => { expect(executionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check there is a waitpoint blocking the parent run - const runWaitpointsBefore = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, + const runWaitpointsBefore = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: run.id, }); expect(runWaitpointsBefore.length).toBe(waitpointCount); @@ -668,13 +665,11 @@ describe("RunEngine Waitpoints", () => { expect(executionData2?.snapshot.executionStatus).toBe("EXECUTING"); //check there are no waitpoints blocking the parent run - const runWaitpoints = await prisma.taskRunWaitpoint.findMany({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, + const runWaitpoints = await readRunBlockEdgesForArm({ + arm, + prisma, + redisOptions, + runId: run.id, }); expect(runWaitpoints.length).toBe(0); } @@ -690,7 +685,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -729,7 +725,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -763,6 +759,7 @@ describe("RunEngine Waitpoints", () => { //create a manual waitpoint with timeout const timeout = new Date(Date.now() + 1_000); const result = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, timeout, @@ -782,14 +779,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check there is a waitpoint blocking the parent run - const runWaitpointBefore = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpointBefore = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpointBefore?.waitpointId).toBe(result.waitpoint.id); let event: EventBusEventArgs<"workerNotification">[0] | undefined = undefined; @@ -816,20 +807,15 @@ describe("RunEngine Waitpoints", () => { expect(notificationEvent.run.id).toBe(run.id); //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); - const waitpoint2 = await prisma.waitpoint.findUnique({ - where: { - id: result.waitpoint.id, - }, + const waitpoint2 = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: result.waitpoint.id, }); assertNonNullable(waitpoint2); expect(waitpoint2.status).toBe("COMPLETED"); @@ -847,7 +833,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -886,7 +873,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -921,6 +908,7 @@ describe("RunEngine Waitpoints", () => { //create a manual waitpoint with timeout const result = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, @@ -941,14 +929,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check there is a waitpoint blocking the parent run - const runWaitpointBefore = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpointBefore = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpointBefore?.waitpointId).toBe(result.waitpoint.id); let event: EventBusEventArgs<"workerNotification">[0] | undefined = undefined; @@ -971,20 +953,15 @@ describe("RunEngine Waitpoints", () => { expect(notificationEvent.run.id).toBe(run.id); //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); - const waitpoint2 = await prisma.waitpoint.findUnique({ - where: { - id: result.waitpoint.id, - }, + const waitpoint2 = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: result.waitpoint.id, }); assertNonNullable(waitpoint2); expect(waitpoint2.status).toBe("COMPLETED"); @@ -998,7 +975,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -1037,7 +1015,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_p1234", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -1072,6 +1050,7 @@ describe("RunEngine Waitpoints", () => { //create a manual waitpoint with timeout const result = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, @@ -1082,6 +1061,7 @@ describe("RunEngine Waitpoints", () => { expect(result.waitpoint.userProvidedIdempotencyKey).toBe(true); const sameWaitpointResult = await engine.createManualWaitpoint({ + waitpointMintKind: arm, environmentId: authenticatedEnvironment.id, projectId: authenticatedEnvironment.projectId, idempotencyKey, @@ -1101,14 +1081,8 @@ describe("RunEngine Waitpoints", () => { expect(executionData?.snapshot.executionStatus).toBe("EXECUTING_WITH_WAITPOINTS"); //check there is a waitpoint blocking the parent run - const runWaitpointBefore = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpointBefore = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpointBefore?.waitpointId).toBe(result.waitpoint.id); let event: EventBusEventArgs<"workerNotification">[0] | undefined = undefined; @@ -1131,20 +1105,15 @@ describe("RunEngine Waitpoints", () => { expect(notificationEvent.run.id).toBe(run.id); //check there are no waitpoints blocking the parent run - const runWaitpoint = await prisma.taskRunWaitpoint.findFirst({ - where: { - taskRunId: run.id, - }, - include: { - waitpoint: true, - }, - }); + const runWaitpoint = + (await readRunBlockEdgesForArm({ arm, prisma, redisOptions, runId: run.id }))[0] ?? null; expect(runWaitpoint).toBeNull(); - const waitpoint2 = await prisma.waitpoint.findUnique({ - where: { - id: result.waitpoint.id, - }, + const waitpoint2 = await readWaitpointForArm({ + arm, + prisma, + redisOptions, + waitpointId: result.waitpoint.id, }); assertNonNullable(waitpoint2); expect(waitpoint2.status).toBe("COMPLETED"); @@ -1160,7 +1129,8 @@ describe("RunEngine Waitpoints", () => { //create environment const authenticatedEnvironment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); - const engine = new RunEngine({ + const engine = createTestEngine({ + waitpointArm: arm, prisma, worker: { redis: redisOptions, @@ -1195,7 +1165,7 @@ describe("RunEngine Waitpoints", () => { const run = await engine.trigger( { number: 1, - friendlyId: "run_snapshotsince", + friendlyId: freshRunFriendlyId(arm), environment: authenticatedEnvironment, taskIdentifier, payload: "{}", @@ -1225,6 +1195,7 @@ describe("RunEngine Waitpoints", () => { // Block the run with a waitpoint (snapshot 2) const { waitpoint } = await engine.createDateTimeWaitpoint({ + waitpointMintKind: arm, projectId: authenticatedEnvironment.project.id, environmentId: authenticatedEnvironment.id, completedAfter: new Date(Date.now() + 100), @@ -1261,8 +1232,11 @@ describe("RunEngine Waitpoints", () => { expect(Array.isArray(snap.completedWaitpoints)).toBe(true); } - // At least one snapshot should have a completed waitpoint - expect(sinceFirst.some((snap) => snap.completedWaitpoints.length === 1)).toBe(true); + // See the note above: the store arm cannot see completed waitpoints until the + // snapshot lane reads the record set back. + if (arm === "legacy") { + expect(sinceFirst.some((snap) => snap.completedWaitpoints.length === 1)).toBe(true); + } // If any completedWaitpoints exist, check output is not an error const withCompleted = sinceFirst.find((snap) => snap.completedWaitpoints.length === 1); diff --git a/internal-packages/run-engine/src/engine/types.ts b/internal-packages/run-engine/src/engine/types.ts index 71dcc424a1f..be35641c79e 100644 --- a/internal-packages/run-engine/src/engine/types.ts +++ b/internal-packages/run-engine/src/engine/types.ts @@ -1,3 +1,4 @@ +import type { WaitpointMintKind } from "./waitpointCoordinator/types.js"; import { type RedisOptions } from "@internal/redis"; import type { Meter, Tracer } from "@internal/tracing"; import type { Logger, LogLevel } from "@trigger.dev/core/logger"; @@ -136,6 +137,13 @@ export type RunEngineOptions = { cache?: { redis: RedisOptions; }; + /** + * The waitpoint store. Absent means the store arm is unreachable and every waitpoint + * operation routes to Postgres, whatever an organization's mint flag says. + */ + waitpointStore?: { + redis: RedisOptions; + }; batchQueue?: { redis: RedisOptions; drr?: Partial; @@ -307,6 +315,11 @@ export type HeartbeatTimeouts = { }; export type TriggerParams = { + /** + * Which coordinator mints this run's associated waitpoint, when a parent waits on it. + * Resolved from the organization's flag by the caller; absent means legacy. + */ + waitpointMintKind?: WaitpointMintKind; number?: number; friendlyId: string; environment: MinimalAuthenticatedEnvironment; diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts index 88fb6e7e4e0..ffbdf213019 100644 --- a/internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts @@ -10,6 +10,7 @@ import { fetchWaitpointsInChunks } from "../systems/executionSnapshotSystem.js"; import { envelopeSourceFromWaitpointRow } from "./completionEnvelopeSource.js"; import type { AssociatedWaitpointData, + CreateBatchWaitpointParams, ClearRunBlockStateParams, CompleteParams, CompleteResult, @@ -296,6 +297,45 @@ export class LegacyPostgresWaitpointCoordinator implements WaitpointCoordinator return { kind: "created", waitpoint }; } + /** + * The BATCH waitpoint for a batch, keyed on the batch id as its idempotency key. + * + * The P2002 catch IS the duplicate-batch contract: a second call for the same batch + * collides on the idempotencyKey unique index, and null is the caller's "this batch + * already has one" signal rather than an error. It stays on this arm because the code + * is dead against a non-Postgres store, where NX reports the duplicate instead. + */ + async createBatchWaitpoint({ + batchId, + environmentId, + projectId, + tx, + }: CreateBatchWaitpointParams): Promise { + try { + return await this.runStore.createWaitpoint( + { + data: { + // From the batch, not the blocked run: the create passes only completedByBatchId, + // which is the owner the router validates against. + ...mintWaitpointIdFor(batchId), + type: "BATCH", + idempotencyKey: batchId, + userProvidedIdempotencyKey: false, + completedByBatchId: batchId, + environmentId, + projectId, + }, + }, + tx + ); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return null; + } + throw error; + } + } + async createManualWaitpoint({ runId, environmentId, diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.test.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.test.ts new file mode 100644 index 00000000000..5d5d577b30a --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.test.ts @@ -0,0 +1,279 @@ +import { getMeter } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { generateWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { Waitpoint } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import { UnclassifiableWaitpointId } from "../errors.js"; +import { WaitpointRouterCoordinator } from "./routerCoordinator.js"; +import type { CompletionEnvelopeSource, RunBlockEdge, WaitpointCoordinator } from "./types.js"; + +const LEGACY_ID = "waitpoint_ckabc123def456ghi789jkl"; +const logger = new Logger("routerCoordinator.test", "error"); + +function storeId() { + return generateWaitpointId("MANUAL"); +} + +/** + * A recording double, not a mock: a real object satisfying the seam that remembers what it + * was asked. The router's whole job is dispatch, so what each arm receives IS the assertion. + */ +function arm(name: string, calls: string[], overrides: Partial = {}) { + const base: WaitpointCoordinator = { + async clearRunBlockState(params) { + calls.push(`${name}.clearRunBlockState:${JSON.stringify(params.edgeIds ?? null)}`); + return { count: params.edgeIds?.length ?? 0 }; + }, + async readRunBlockState(runId) { + calls.push(`${name}.readRunBlockState`); + return []; + }, + async readCompletionEnvelopes(params) { + calls.push(`${name}.readCompletionEnvelopes:${params.waitpointIds.length}`); + return []; + }, + async registerBlocks(params) { + calls.push(`${name}.registerBlocks:${params.waitpointIds.length}`); + return { pendingCount: 0 }; + }, + async registerBlocksLockless(params) { + calls.push(`${name}.registerBlocksLockless:${params.waitpointIds.length}`); + }, + async complete(params) { + calls.push(`${name}.complete`); + return { waitpoint: { id: params.waitpointId } as Waitpoint, blockedRuns: [] }; + }, + async createDateTimeWaitpoint() { + calls.push(`${name}.createDateTimeWaitpoint`); + return { kind: "created", waitpoint: {} as Waitpoint }; + }, + async createManualWaitpoint() { + calls.push(`${name}.createManualWaitpoint`); + return { kind: "created", waitpoint: {} as Waitpoint }; + }, + async createBatchWaitpoint() { + calls.push(`${name}.createBatchWaitpoint`); + return {} as Waitpoint; + }, + mintAssociatedWaitpointData() { + calls.push(`${name}.mintAssociatedWaitpointData`); + return {} as never; + }, + async createAssociatedWaitpoint(params) { + calls.push(`${name}.createAssociatedWaitpoint`); + return { id: params.data.id } as Waitpoint; + }, + }; + + return { ...base, ...overrides }; +} + +function router(calls: string[], opts: { withStore?: boolean } = { withStore: true }) { + return new WaitpointRouterCoordinator({ + legacy: arm("legacy", calls), + store: opts.withStore ? arm("store", calls) : undefined, + logger, + meter: getMeter("routerCoordinator.test"), + }); +} + +describe("WaitpointRouterCoordinator", () => { + describe("routing an operation by id shape", () => { + it("sends a legacy id to the legacy arm", async () => { + const calls: string[] = []; + await router(calls).complete({ waitpointId: LEGACY_ID }); + expect(calls).toEqual(["legacy.complete"]); + }); + + it("sends a store id to the store arm", async () => { + const calls: string[] = []; + await router(calls).complete({ waitpointId: storeId() }); + expect(calls).toEqual(["store.complete"]); + }); + + it("throws on a store id when no store arm is configured", async () => { + const calls: string[] = []; + await expect( + router(calls, { withStore: false }).complete({ waitpointId: storeId() }) + ).rejects.toBeInstanceOf(UnclassifiableWaitpointId); + expect(calls).toEqual([]); + }); + }); + + describe("fanning a mixed run across both arms", () => { + it("concatenates readRunBlockState from both", async () => { + const calls: string[] = []; + const legacyEdge = { id: "edge_legacy" } as RunBlockEdge; + const storeEdge = { id: "edge_store" } as RunBlockEdge; + const coordinator = new WaitpointRouterCoordinator({ + legacy: arm("legacy", calls, { readRunBlockState: async () => [legacyEdge] }), + store: arm("store", calls, { readRunBlockState: async () => [storeEdge] }), + logger, + meter: getMeter("routerCoordinator.test"), + }); + + const edges = await coordinator.readRunBlockState("run_1"); + + expect(edges.map((e) => e.id)).toEqual(["edge_legacy", "edge_store"]); + }); + + it("sums the pending count across both arms", async () => { + const calls: string[] = []; + const coordinator = new WaitpointRouterCoordinator({ + legacy: arm("legacy", calls, { registerBlocks: async () => ({ pendingCount: 1 }) }), + store: arm("store", calls, { registerBlocks: async () => ({ pendingCount: 2 }) }), + logger, + meter: getMeter("routerCoordinator.test"), + }); + + const { pendingCount } = await coordinator.registerBlocks({ + runId: "run_1", + waitpointIds: [LEGACY_ID, storeId()], + projectId: "proj_1", + client: {} as never, + }); + + expect(pendingCount).toBe(3); + }); + + it("gives each arm only the ids it owns", async () => { + const calls: string[] = []; + await router(calls).registerBlocks({ + runId: "run_1", + waitpointIds: [LEGACY_ID, storeId(), storeId()], + projectId: "proj_1", + client: {} as never, + }); + + expect(calls.sort()).toEqual(["legacy.registerBlocks:1", "store.registerBlocks:2"]); + }); + + it("concatenates completion envelopes from both arms", async () => { + const calls: string[] = []; + const coordinator = new WaitpointRouterCoordinator({ + legacy: arm("legacy", calls, { + readCompletionEnvelopes: async () => [{ id: "a" } as CompletionEnvelopeSource], + }), + store: arm("store", calls, { + readCompletionEnvelopes: async () => [{ id: "b" } as CompletionEnvelopeSource], + }), + logger, + meter: getMeter("routerCoordinator.test"), + }); + + const sources = await coordinator.readCompletionEnvelopes({ + runId: "run_1", + waitpointIds: [LEGACY_ID, storeId()], + }); + + expect(sources.map((s) => s.id)).toEqual(["a", "b"]); + }); + + it("skips an arm that owns none of the requested ids", async () => { + const calls: string[] = []; + await router(calls).registerBlocks({ + runId: "run_1", + waitpointIds: [LEGACY_ID], + projectId: "proj_1", + client: {} as never, + }); + + expect(calls).toEqual(["legacy.registerBlocks:1"]); + }); + }); + + describe("clearing block state", () => { + // The trap this pins: an omitted edgeIds means "clear the whole run", so a partition + // that comes out empty must send [] and never omit, or it wipes the other arm's edges. + it("sends an empty array, never an omission, to the arm with no edges", async () => { + const calls: string[] = []; + await router(calls).clearRunBlockState({ runId: "run_1", edgeIds: ["ckLegacyEdgeId"] }); + + expect(calls.sort()).toEqual([ + 'legacy.clearRunBlockState:["ckLegacyEdgeId"]', + "store.clearRunBlockState:[]", + ]); + }); + + it("routes a store edge id by the waitpoint id it carries", async () => { + const calls: string[] = []; + const edgeId = `${storeId()}#0`; + await router(calls).clearRunBlockState({ runId: "run_1", edgeIds: [edgeId] }); + + expect(calls.sort()).toEqual([ + "legacy.clearRunBlockState:[]", + `store.clearRunBlockState:["${edgeId}"]`, + ]); + }); + + it("forwards a full clear to both arms with edgeIds omitted", async () => { + const calls: string[] = []; + await router(calls).clearRunBlockState({ runId: "run_1" }); + + expect(calls.sort()).toEqual([ + "legacy.clearRunBlockState:null", + "store.clearRunBlockState:null", + ]); + }); + + it("sums the cleared counts", async () => { + const calls: string[] = []; + const { count } = await router(calls).clearRunBlockState({ + runId: "run_1", + edgeIds: ["ckLegacyEdgeId", `${storeId()}#0`], + }); + + expect(count).toBe(2); + }); + }); + + describe("routing a create by mint kind", () => { + it("sends a legacy mint to the legacy arm", async () => { + const calls: string[] = []; + await router(calls).createManualWaitpoint({ + mintKind: "legacy", + environmentId: "env_1", + projectId: "proj_1", + }); + + expect(calls).toEqual(["legacy.createManualWaitpoint"]); + }); + + it("sends a store mint to the store arm", async () => { + const calls: string[] = []; + await router(calls).createManualWaitpoint({ + mintKind: "store", + environmentId: "env_1", + projectId: "proj_1", + }); + + expect(calls).toEqual(["store.createManualWaitpoint"]); + }); + + // Fail safe at the mint, unlike an operation on an existing id: a misconfigured deploy + // must not fail every trigger for a flipped organization. + it("falls back to legacy when a store mint finds no store arm", async () => { + const calls: string[] = []; + await router(calls, { withStore: false }).createManualWaitpoint({ + mintKind: "store", + environmentId: "env_1", + projectId: "proj_1", + }); + + expect(calls).toEqual(["legacy.createManualWaitpoint"]); + }); + }); + + describe("routing an associated waitpoint", () => { + it("routes createAssociatedWaitpoint by the shape of the minted id", async () => { + const calls: string[] = []; + const id = storeId(); + await router(calls).createAssociatedWaitpoint({ + runId: "run_1", + data: { id } as never, + }); + + expect(calls).toEqual(["store.createAssociatedWaitpoint"]); + }); + }); +}); diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.ts new file mode 100644 index 00000000000..821e15cef2e --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/routerCoordinator.ts @@ -0,0 +1,281 @@ +import type { Counter, Meter } from "@internal/tracing"; +import type { Logger } from "@trigger.dev/core/logger"; +import { deriveWaitpointIdFromAnchor, parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { Waitpoint } from "@trigger.dev/database"; +import { UnclassifiableWaitpointId } from "../errors.js"; +import { waitpointIdFromEdgeField } from "./keys.js"; +import type { + AssociatedWaitpointData, + ClearRunBlockStateParams, + CompleteParams, + CompleteResult, + CompletionEnvelopeSource, + CreateBatchWaitpointParams, + CreateDateTimeWaitpointParams, + CreateManualWaitpointParams, + CreateWaitpointResult, + ReadCompletionEnvelopesParams, + RegisterBlocksLocklessParams, + RegisterBlocksParams, + RunBlockEdge, + WaitpointCoordinator, + WaitpointMintKind, +} from "./types.js"; + +export type WaitpointRouterCoordinatorOptions = { + legacy: WaitpointCoordinator; + /** Absent when no waitpoint store is configured, which makes the store path unreachable. */ + store?: WaitpointCoordinator; + logger: Logger; + meter: Meter; +}; + +/** + * Chooses which arm owns a waitpoint, and nothing else. + * + * Every method here is a partition followed by delegation. It holds no store client and no + * Prisma client of its own, so a branch that is not about ownership does not belong here. + * + * Two different rules, deliberately: + * + * - An OPERATION routes on the id's shape. The id already exists, so its residency is a + * fact. A store-shaped id with no store arm configured throws, because guessing would + * silently operate on the wrong system. + * - A CREATE routes on the caller's mint kind. There is no id yet, so nothing can be + * misrouted. A store mint with no store arm falls back to legacy and says so: refusing + * would turn one process with a bad configuration into a trigger outage for every + * organization that has the flag set. + */ +export class WaitpointRouterCoordinator implements WaitpointCoordinator { + private readonly legacy: WaitpointCoordinator; + private readonly store?: WaitpointCoordinator; + private readonly logger: Logger; + private readonly legacyAnchorDowngrades: Counter; + + constructor(options: WaitpointRouterCoordinatorOptions) { + this.legacy = options.legacy; + this.store = options.store; + this.logger = options.logger; + this.legacyAnchorDowngrades = options.meter.createCounter( + "waitpoint.legacy_anchor_downgrades", + { + description: + "Store mints that fell back to legacy because the anchor run carried a legacy id", + } + ); + } + + async clearRunBlockState(params: ClearRunBlockStateParams): Promise<{ count: number }> { + // An omitted edgeIds is the terminal "clear the whole run", so it must reach both arms + // as an omission. A partition, by contrast, must send [] to the arm with nothing to + // drain: omitting there would clear that arm's remaining edges for the run. + if (!params.edgeIds) { + const [legacy, store] = await Promise.all([ + this.legacy.clearRunBlockState(params), + this.store?.clearRunBlockState(params), + ]); + + return { count: legacy.count + (store?.count ?? 0) }; + } + + const split = this.#partitionEdgeIds(params.edgeIds); + const [legacy, store] = await Promise.all([ + this.legacy.clearRunBlockState({ ...params, edgeIds: split.legacy }), + this.store?.clearRunBlockState({ ...params, edgeIds: split.store }), + ]); + + return { count: legacy.count + (store?.count ?? 0) }; + } + + /** + * Both arms, always, because a run can be blocked by one of each and the pending set is + * only correct as the union. The store read is one round trip against possibly-absent + * keys, which answers empty for a run that never touched the store. + */ + async readRunBlockState(runId: string): Promise { + const [legacy, store] = await Promise.all([ + this.legacy.readRunBlockState(runId), + this.store?.readRunBlockState(runId), + ]); + + return [...legacy, ...(store ?? [])]; + } + + async readCompletionEnvelopes( + params: ReadCompletionEnvelopesParams + ): Promise { + const split = this.#partitionWaitpointIds(params.waitpointIds); + + const [legacy, store] = await Promise.all([ + split.legacy.length + ? this.legacy.readCompletionEnvelopes({ ...params, waitpointIds: split.legacy }) + : [], + split.store.length + ? this.#requireStore(split.store[0]!).readCompletionEnvelopes({ + ...params, + waitpointIds: split.store, + }) + : [], + ]); + + return [...legacy, ...store]; + } + + /** + * The dual pending check. Each arm counts only the ids it owns, and the sum is the run's + * whole pending set, so a run blocked by one waitpoint of each kind stays blocked until + * both complete. + */ + async registerBlocks(params: RegisterBlocksParams): Promise<{ pendingCount: number }> { + const split = this.#partitionWaitpointIds(params.waitpointIds); + + const [legacy, store] = await Promise.all([ + split.legacy.length + ? this.legacy.registerBlocks({ ...params, waitpointIds: split.legacy }) + : undefined, + split.store.length + ? this.#requireStore(split.store[0]!).registerBlocks({ + ...params, + waitpointIds: split.store, + }) + : undefined, + ]); + + return { pendingCount: (legacy?.pendingCount ?? 0) + (store?.pendingCount ?? 0) }; + } + + async registerBlocksLockless(params: RegisterBlocksLocklessParams): Promise { + const split = this.#partitionWaitpointIds(params.waitpointIds); + + await Promise.all([ + split.legacy.length + ? this.legacy.registerBlocksLockless({ ...params, waitpointIds: split.legacy }) + : undefined, + split.store.length + ? this.#requireStore(split.store[0]!).registerBlocksLockless({ + ...params, + waitpointIds: split.store, + }) + : undefined, + ]); + } + + async complete(params: CompleteParams): Promise { + return this.#armFor(params.waitpointId).complete(params); + } + + async createDateTimeWaitpoint( + params: CreateDateTimeWaitpointParams + ): Promise { + return this.#armForMint(params.mintKind).createDateTimeWaitpoint(params); + } + + async createManualWaitpoint(params: CreateManualWaitpointParams): Promise { + return this.#armForMint(params.mintKind).createManualWaitpoint(params); + } + + async createBatchWaitpoint(params: CreateBatchWaitpointParams): Promise { + return this.#armForMint(params.mintKind).createBatchWaitpoint(params); + } + + /** + * A RUN waitpoint's store id is derived from its anchor run's id body, so an anchor that + * is not itself a run-ops id has nothing to derive from. That run keeps a legacy + * waitpoint even in a flipped organization, which is the coexistence rule. + * + * Counted, not just logged: an organization whose runs are all legacy-shaped mints zero + * store waitpoints, and a wave gate that reads "no store problems" off an empty sample + * is measuring nothing. + */ + mintAssociatedWaitpointData(params: { + projectId: string; + environmentId: string; + anchorRunId: string; + mintKind?: WaitpointMintKind; + }): AssociatedWaitpointData { + const mintKind = params.mintKind ?? "legacy"; + + if (mintKind === "store" && !this.#canDeriveFromAnchor(params.anchorRunId)) { + this.legacyAnchorDowngrades.add(1); + this.logger.info("waitpoint mint fell back to legacy: the anchor run is not a run-ops id", { + anchorRunId: params.anchorRunId, + }); + return this.legacy.mintAssociatedWaitpointData(params); + } + + return this.#armForMint(mintKind).mintAssociatedWaitpointData(params); + } + + #canDeriveFromAnchor(anchorRunId: string | undefined): boolean { + return ( + anchorRunId !== undefined && deriveWaitpointIdFromAnchor(anchorRunId, "RUN") !== undefined + ); + } + + /** Routes on the minted id, so it lands wherever mintAssociatedWaitpointData put it. */ + async createAssociatedWaitpoint(params: { + runId: string; + data: AssociatedWaitpointData; + }): Promise { + return this.#armFor(params.data.id).createAssociatedWaitpoint(params); + } + + #armFor(waitpointId: string): WaitpointCoordinator { + return parseWaitpointId(waitpointId).format === "b32hexW" + ? this.#requireStore(waitpointId) + : this.legacy; + } + + #armForMint(mintKind: WaitpointMintKind): WaitpointCoordinator { + if (mintKind !== "store") { + return this.legacy; + } + + if (!this.store) { + this.logger.error( + "waitpoint mint asked for the store with no store configured; minting legacy", + { mintKind } + ); + return this.legacy; + } + + return this.store; + } + + #requireStore(waitpointId: string): WaitpointCoordinator { + if (!this.store) { + throw new UnclassifiableWaitpointId(waitpointId); + } + + return this.store; + } + + #partitionWaitpointIds(waitpointIds: string[]): { legacy: string[]; store: string[] } { + const legacy: string[] = []; + const store: string[] = []; + + for (const waitpointId of waitpointIds) { + (parseWaitpointId(waitpointId).format === "b32hexW" ? store : legacy).push(waitpointId); + } + + return { legacy, store }; + } + + /** + * A store edge id is `#`; a legacy edge id is a Postgres row id + * with no separator, so the helper reports undefined for it and it partitions legacy. + */ + #partitionEdgeIds(edgeIds: string[]): { legacy: string[]; store: string[] } { + const legacy: string[] = []; + const store: string[] = []; + + for (const edgeId of edgeIds) { + const waitpointId = waitpointIdFromEdgeField(edgeId); + const isStore = + waitpointId !== undefined && parseWaitpointId(waitpointId).format === "b32hexW"; + (isStore ? store : legacy).push(edgeId); + } + + return { legacy, store }; + } +} diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.test.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.test.ts new file mode 100644 index 00000000000..c7f17a19dba --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.test.ts @@ -0,0 +1,439 @@ +import { createRedisClient, type RedisOptions } from "@internal/redis"; +import { containerTest } from "@internal/testcontainers"; +import { getMeter } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { generateRunOpsId, generateWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect } from "vitest"; +import { PostgresRunStore } from "@internal/run-store"; +import { setupAuthenticatedEnvironment } from "../tests/setup.js"; +import { runBlockKeys } from "./keys.js"; +import { StoreWaitpointCoordinatorArm } from "./storeArm.js"; +import { WaitpointStoreCoordinator, type WaitpointRecordInput } from "./storeCoordinator.js"; + +const RUN_ID = "run_blocked"; +const NOW = "2026-08-26T12:00:00.000Z"; + +function setup(redisOptions: RedisOptions, prisma: PrismaClient) { + const store = new WaitpointStoreCoordinator({ redisOptions }); + const arm = new StoreWaitpointCoordinatorArm({ + store, + runStore: new PostgresRunStore({ prisma, readOnlyPrisma: prisma }), + logger: new Logger("storeArm.test", "error"), + meter: getMeter("storeArm.test"), + }); + + return { store, arm }; +} + +function record( + id: string, + environmentId: string, + projectId: string, + overrides: Partial = {} +): WaitpointRecordInput { + return { + id, + friendlyId: `waitpoint_${id}`, + type: "MANUAL", + environmentId, + projectId, + createdAt: NOW, + updatedAt: NOW, + userProvidedIdempotencyKey: false, + tags: [], + idempotencyKey: `idem_${id}`, + ...overrides, + }; +} + +describe("StoreWaitpointCoordinatorArm", () => { + containerTest( + "reports COMPLETED once a blocked waitpoint is delivered", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const waitpointId = generateWaitpointId("MANUAL"); + await store.createIfAbsent({ + record: record(waitpointId, environment.id, environment.projectId), + status: "PENDING", + }); + + const { pendingCount } = await arm.registerBlocks({ + runId: RUN_ID, + waitpointIds: [waitpointId], + projectId: environment.projectId, + client: prisma, + }); + expect(pendingCount).toBe(1); + + const beforeComplete = await arm.readRunBlockState(RUN_ID); + expect(beforeComplete[0]!.waitpoint.status).toBe("PENDING"); + + await arm.complete({ waitpointId, output: { value: "42", isError: false } }); + + const afterComplete = await arm.readRunBlockState(RUN_ID); + expect(afterComplete).toHaveLength(1); + expect(afterComplete[0]!.waitpoint.status).toBe("COMPLETED"); + expect(afterComplete[0]!.waitpoint.type).toBe("MANUAL"); + } finally { + await store.quit(); + } + } + ); + + // I10, and the only premature-resume counterexample either TLA+ campaign produced. A + // run-shard loss removes the pending entry while the edge survives; "not pending, + // therefore complete" would resume a run whose waitpoint never completed. + containerTest( + "reports PENDING for an edge that is in neither the pending nor the delivered set", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + const redis = createRedisClient(redisOptions); + + try { + const waitpointId = generateWaitpointId("MANUAL"); + await store.createIfAbsent({ + record: record(waitpointId, environment.id, environment.projectId), + status: "PENDING", + }); + await arm.registerBlocks({ + runId: RUN_ID, + waitpointIds: [waitpointId], + projectId: environment.projectId, + client: prisma, + }); + + await redis.srem(runBlockKeys(RUN_ID).pend, waitpointId); + + const edges = await arm.readRunBlockState(RUN_ID); + expect(edges).toHaveLength(1); + expect(edges[0]!.waitpoint.status).toBe("PENDING"); + } finally { + await redis.quit(); + await store.quit(); + } + } + ); + + // The case a "has a completion envelope" rule would wedge forever: a waitpoint may be + // COMPLETED with no envelope, which the reported box models on purpose. + containerTest( + "reports COMPLETED for a waitpoint completed before the run ever blocked on it", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const waitpointId = generateWaitpointId("MANUAL"); + await store.createIfAbsent({ + record: record(waitpointId, environment.id, environment.projectId), + status: "COMPLETED", + }); + + const { pendingCount } = await arm.registerBlocks({ + runId: RUN_ID, + waitpointIds: [waitpointId], + projectId: environment.projectId, + client: prisma, + }); + + expect(pendingCount).toBe(0); + + const edges = await arm.readRunBlockState(RUN_ID); + expect(edges[0]!.waitpoint.status).toBe("COMPLETED"); + } finally { + await store.quit(); + } + } + ); + + // §5.4's guard. Unmodeled in both campaigns, so this assertion is its only protection. + containerTest( + "refuses a lockless absorb when the parent BATCH waitpoint is absent", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const itemWaitpointId = generateWaitpointId("RUN"); + const batchWaitpointId = generateWaitpointId("BATCH"); + + await expect( + arm.registerBlocksLockless({ + runId: RUN_ID, + waitpointIds: [itemWaitpointId], + projectId: environment.projectId, + batchId: "batch_1", + batchIndex: 0, + batchWaitpointId, + }) + ).rejects.toThrow(/BATCH waitpoint/); + } finally { + await store.quit(); + } + } + ); + + // Present-but-not-pending is the half of the guard a presence-only check would miss. + containerTest( + "refuses a lockless absorb when the parent BATCH waitpoint is already complete", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const batchWaitpointId = generateWaitpointId("BATCH"); + await store.createIfAbsent({ + record: record(batchWaitpointId, environment.id, environment.projectId, { + type: "BATCH", + }), + status: "PENDING", + }); + await arm.registerBlocks({ + runId: RUN_ID, + waitpointIds: [batchWaitpointId], + projectId: environment.projectId, + client: prisma, + }); + await arm.complete({ waitpointId: batchWaitpointId, output: undefined }); + + await expect( + arm.registerBlocksLockless({ + runId: RUN_ID, + waitpointIds: [generateWaitpointId("RUN")], + projectId: environment.projectId, + batchId: "batch_1", + batchIndex: 0, + batchWaitpointId, + }) + ).rejects.toThrow(/BATCH waitpoint/); + } finally { + await store.quit(); + } + } + ); + + containerTest( + "allows a lockless absorb while the parent BATCH waitpoint is pending", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const batchWaitpointId = generateWaitpointId("BATCH"); + await store.createIfAbsent({ + record: record(batchWaitpointId, environment.id, environment.projectId, { + type: "BATCH", + }), + status: "PENDING", + }); + await arm.registerBlocks({ + runId: RUN_ID, + waitpointIds: [batchWaitpointId], + projectId: environment.projectId, + client: prisma, + }); + + const itemWaitpointId = generateWaitpointId("RUN"); + await store.createIfAbsent({ + record: record(itemWaitpointId, environment.id, environment.projectId, { type: "RUN" }), + status: "PENDING", + }); + + await arm.registerBlocksLockless({ + runId: RUN_ID, + waitpointIds: [itemWaitpointId], + projectId: environment.projectId, + batchId: "batch_1", + batchIndex: 0, + batchWaitpointId, + }); + + // The parent's BATCH waitpoint is still pending after the item absorbed, which is + // the invariant: the pending set is never momentarily empty mid-absorb. + const edges = await arm.readRunBlockState(RUN_ID); + const stillPending = edges.filter((e) => e.waitpoint.status === "PENDING"); + expect(stillPending.map((e) => e.waitpoint.id).sort()).toEqual( + [batchWaitpointId, itemWaitpointId].sort() + ); + } finally { + await store.quit(); + } + } + ); + + containerTest( + "writes the MANUAL projection row after the store commit", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const result = await arm.createManualWaitpoint({ + mintKind: "store", + environmentId: environment.id, + projectId: environment.projectId, + tags: ["alpha"], + }); + + expect(result.kind).toBe("created"); + + const row = await prisma.waitpoint.findFirst({ where: { id: result.waitpoint.id } }); + expect(row?.type).toBe("MANUAL"); + expect(row?.tags).toEqual(["alpha"]); + + // The store is the system of record; the row is a projection of it. + const held = await store.readWaitpoint(result.waitpoint.id); + expect(held?.status).toBe("PENDING"); + } finally { + await store.quit(); + } + } + ); + + // The token API and dashboard read status, output and completedAt from the projection + // row, so a completion that never reaches it reports a finished token as still waiting. + containerTest( + "reflects a MANUAL completion onto the projection row", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const created = await arm.createManualWaitpoint({ + mintKind: "store", + environmentId: environment.id, + projectId: environment.projectId, + }); + + await arm.complete({ + waitpointId: created.waitpoint.id, + output: { value: '{"done":true}', type: "application/json", isError: false }, + }); + + const row = await prisma.waitpoint.findFirst({ where: { id: created.waitpoint.id } }); + expect(row?.status).toBe("COMPLETED"); + expect(row?.output).toBe('{"done":true}'); + expect(row?.outputIsError).toBe(false); + expect(row?.completedAt).not.toBeNull(); + } finally { + await store.quit(); + } + } + ); + + // An unwired caller must fail, never silently disable the guard. + containerTest( + "refuses a lockless absorb that arrives with no parent BATCH waitpoint id", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + await expect( + arm.registerBlocksLockless({ + runId: RUN_ID, + waitpointIds: [generateWaitpointId("RUN")], + projectId: environment.projectId, + batchId: "batch_1", + batchIndex: 0, + }) + ).rejects.toThrow(/no parent .*BATCH waitpoint id/); + } finally { + await store.quit(); + } + } + ); + + containerTest( + "returns the cached waitpoint for a repeated idempotency key", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const args = { + mintKind: "store" as const, + environmentId: environment.id, + projectId: environment.projectId, + idempotencyKey: "same-key", + }; + + const first = await arm.createManualWaitpoint(args); + const second = await arm.createManualWaitpoint(args); + + expect(first.kind).toBe("created"); + expect(second.kind).toBe("cached"); + expect(second.waitpoint.id).toBe(first.waitpoint.id); + } finally { + await store.quit(); + } + } + ); + + containerTest( + "returns null when the batch already has a waitpoint", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const batchId = `batch_${generateRunOpsId()}`; + const args = { + batchId, + environmentId: environment.id, + projectId: environment.projectId, + mintKind: "store" as const, + }; + + const first = await arm.createBatchWaitpoint(args); + expect(first).not.toBeNull(); + expect(first!.type).toBe("BATCH"); + expect(first!.completedByBatchId).toBe(batchId); + + const second = await arm.createBatchWaitpoint(args); + expect(second).toBeNull(); + } finally { + await store.quit(); + } + } + ); + + containerTest( + "creates the RUN waitpoint at the anchor-derived id, idempotently", + async ({ prisma, redisOptions }) => { + const environment = await setupEnvironment(prisma); + const { store, arm } = setup(redisOptions, prisma); + + try { + const runId = generateRunOpsId(); + const data = arm.mintAssociatedWaitpointData({ + projectId: environment.projectId, + environmentId: environment.id, + anchorRunId: runId, + }); + + // Pure function of the run id, which is what removes the need for a lock. + expect(data.id.slice(0, 24)).toBe(runId.slice(0, 24)); + + const first = await arm.createAssociatedWaitpoint({ runId, data }); + const second = await arm.createAssociatedWaitpoint({ runId, data }); + + expect(first.id).toBe(data.id); + expect(second.id).toBe(data.id); + expect(second.status).toBe("PENDING"); + } finally { + await store.quit(); + } + } + ); +}); + +async function setupEnvironment(prisma: PrismaClient) { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + return { id: environment.id, projectId: environment.project.id }; +} diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.ts new file mode 100644 index 00000000000..ed828121c36 --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.ts @@ -0,0 +1,563 @@ +import type { Meter, Counter } from "@internal/tracing"; +import type { RunStore } from "@internal/run-store"; +import type { Logger } from "@trigger.dev/core/logger"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { + deriveWaitpointIdFromAnchor, + generateWaitpointId, + parseWaitpointId, + WaitpointId, +} from "@trigger.dev/core/v3/isomorphic"; +import type { Waitpoint } from "@trigger.dev/database"; +import { nanoid } from "nanoid"; +import type { + BlockEdge, + WaitpointCompletion, + WaitpointRecordInput, + WaitpointStoreCoordinator, +} from "./storeCoordinator.js"; +import type { + AssociatedWaitpointData, + ClearRunBlockStateParams, + CompleteParams, + CompleteResult, + CompletionEnvelopeSource, + CreateBatchWaitpointParams, + CreateDateTimeWaitpointParams, + CreateManualWaitpointParams, + CreateWaitpointResult, + ReadCompletionEnvelopesParams, + RegisterBlocksLocklessParams, + RegisterBlocksParams, + RunBlockEdge, + WaitpointCoordinator, +} from "./types.js"; +import { toPrismaWaitpoint } from "./waitpointShape.js"; + +export type StoreWaitpointCoordinatorArmOptions = { + store: WaitpointStoreCoordinator; + /** MANUAL projection writes only. Never read for coordination (I6). */ + runStore: RunStore; + logger: Logger; + meter: Meter; +}; + +/** + * Waitpoint coordination against the Redis store. + * + * The store is the system of record. Postgres keeps one derived artefact — the MANUAL + * projection row, written after the store commit so the dashboard and token API keep + * working — and no coordination path ever reads it back. + */ +export class StoreWaitpointCoordinatorArm implements WaitpointCoordinator { + private readonly store: WaitpointStoreCoordinator; + private readonly runStore: RunStore; + private readonly logger: Logger; + + private readonly resumeCrossCheckViolations: Counter; + private readonly batchGuardViolations: Counter; + private readonly projectionWriteFailures: Counter; + + constructor(options: StoreWaitpointCoordinatorArmOptions) { + this.store = options.store; + this.runStore = options.runStore; + this.logger = options.logger; + + this.resumeCrossCheckViolations = options.meter.createCounter( + "waitpoint.resume_crosscheck_violations", + { description: "Block edges found in neither the pending nor the delivered set" } + ); + this.batchGuardViolations = options.meter.createCounter("waitpoint.batch_guard_violations", { + description: "Lockless absorbs attempted without a pending parent BATCH waitpoint", + }); + this.projectionWriteFailures = options.meter.createCounter( + "waitpoint.projection_write_failures", + { description: "MANUAL projection rows that failed to write after the store commit" } + ); + } + + /** + * The store reports an outcome, not a delete count, and the seam's only consumer of the + * count is a debug log in the run-completion path. So this reports what was asked to + * drain rather than paying a read to confirm it. + */ + async clearRunBlockState({ runId, edgeIds }: ClearRunBlockStateParams): Promise<{ + count: number; + }> { + await this.store.clearBlockState({ runId, edgeIds }); + return { count: edgeIds?.length ?? 0 }; + } + + async readRunBlockState(runId: string): Promise { + const state = await this.store.readBlockState(runId); + const pending = new Set(state.pendingIds); + const delivered = new Set(state.deliveredIds); + + return state.edges.map((edge) => ({ + id: edge.edgeId, + batchId: edge.batchId ?? null, + batchIndex: edge.batchIndex ?? null, + waitpoint: { + id: edge.waitpointId, + status: this.#deriveStatus(runId, edge.waitpointId, pending, delivered), + type: edge.type, + completedAfter: edge.completedAfter ? new Date(edge.completedAfter) : null, + }, + })); + } + + /** + * I10. `runAbsorbBlockers` keeps every edge in exactly one of the pending or delivered + * sets. A run-shard data loss breaks that: the edge survives while its pending entry is + * gone. Reading "not pending, therefore complete" then resumes a run whose waitpoint + * never completed, which is the only premature-resume counterexample either TLA+ + * campaign produced. So an edge in neither set reports PENDING and is counted; the run + * stays blocked and a later sweep heals it. + */ + #deriveStatus( + runId: string, + waitpointId: string, + pending: Set, + delivered: Set + ): "PENDING" | "COMPLETED" { + if (delivered.has(waitpointId)) { + return "COMPLETED"; + } + + if (!pending.has(waitpointId)) { + this.resumeCrossCheckViolations.add(1); + this.logger.error("waitpoint edge is in neither the pending nor the delivered set", { + runId, + waitpointId, + }); + } + + return "PENDING"; + } + + readCompletionEnvelopes( + params: ReadCompletionEnvelopesParams + ): Promise { + return this.store.readCompletionEnvelopes(params); + } + + async registerBlocks(params: RegisterBlocksParams): Promise<{ pendingCount: number }> { + const edges = await this.#buildEdges(params); + const { pendingOfRequested } = await this.store.registerBlocks({ + runId: params.runId, + edges, + }); + + return { pendingCount: pendingOfRequested }; + } + + async registerBlocksLockless(params: RegisterBlocksLocklessParams): Promise { + await this.#assertBatchWaitpointPending(params); + + const edges = await this.#buildEdges(params); + await this.store.registerBlocks({ runId: params.runId, edges }); + } + + /** + * §5.4's guard invariant. A lockless absorb writes item edges one at a time without the + * run lock, which is only safe while the parent's BATCH waitpoint holds the pending set + * open. If it is absent or already complete, a concurrent completion could see an empty + * pending set mid-absorb and resume the parent early. + * + * Scope, stated precisely: this is a PREFLIGHT DETECTOR, not a barrier. It reads the run + * shard, then the absorb writes in a separate operation, so a completion landing between + * the two is detected on the next call, not prevented. Closing that window means moving + * the pending-set assertion inside the absorb script, so check and write share one + * atomic action. + * + * Neither TLA+ campaign models this variant, so until the race harness covers it this + * detector plus the fail-loud on a missing id is the whole protection. + */ + async #assertBatchWaitpointPending(params: RegisterBlocksLocklessParams): Promise { + if (!params.batchWaitpointId) { + // Never silently skip. An unwired caller would disable the guard rather than fail, + // which is the failure mode the guard exists to prevent. + this.batchGuardViolations.add(1); + throw new Error( + `Lockless absorb for run ${params.runId} reached the store arm with no parent ` + + `BATCH waitpoint id, so the pending-set guard has nothing to assert on` + ); + } + + const state = await this.store.readBlockState(params.runId); + if (state.pendingIds.includes(params.batchWaitpointId)) { + return; + } + + this.batchGuardViolations.add(1); + throw new Error( + `Lockless absorb for run ${params.runId} requires the parent BATCH waitpoint ` + + `${params.batchWaitpointId} to be present and pending on the run shard` + ); + } + + /** + * The edge blobs the run shard stores. + * + * `type` comes free from the id, which is what the positional id layout buys. Only + * DATETIME needs a record read, because its `completedAfter` rides the edge so the + * block-state read never has to touch each waitpoint's own key. RUN, BATCH and MANUAL + * skip it, which keeps `triggerAndWait` at one round trip per waitpoint. + */ + async #buildEdges(params: RegisterBlocksLocklessParams): Promise { + const createdAt = new Date().toISOString(); + const dateTimeIds = params.waitpointIds.filter((id) => { + const parsed = parseWaitpointId(id); + return parsed.format === "b32hexW" && parsed.type === "DATETIME"; + }); + const completedAfterById = await this.#readCompletedAfter(dateTimeIds); + + return params.waitpointIds.map((waitpointId) => { + const parsed = parseWaitpointId(waitpointId); + if (parsed.format !== "b32hexW") { + throw new Error(`Waitpoint ${waitpointId} is not a store-format id`); + } + + return { + waitpointId, + batchIndex: params.batchIndex ?? null, + batchId: params.batchId, + spanIdToComplete: params.spanIdToComplete, + createdAt, + type: parsed.type, + completedAfter: completedAfterById.get(waitpointId), + }; + }); + } + + async #readCompletedAfter(waitpointIds: string[]): Promise> { + const found = new Map(); + + for (const waitpointId of waitpointIds) { + const held = await this.store.readWaitpoint(waitpointId); + if (held?.record.completedAfter) { + found.set(waitpointId, held.record.completedAfter); + } + } + + return found; + } + + async complete({ waitpointId, output }: CompleteParams): Promise { + const completion: WaitpointCompletion = { + completedAt: new Date().toISOString(), + outputType: output?.type ?? "application/json", + outputIsError: output?.isError ?? false, + output: output ? { inline: output.value } : null, + }; + + const result = await this.store.complete({ waitpointId, completion }); + + // Deliver onto each watcher's own shard. The complete script returned the watchers + // atomically, so a watcher registered before the flip is always in this list. + for (const watcher of result.watchers) { + await this.store.deliverCompletion({ + runId: watcher.runId, + waitpointId, + completion: result.completion ?? completion, + }); + } + + const held = await this.store.readWaitpoint(waitpointId); + if (!held) { + throw new Error(`Waitpoint ${waitpointId} is not present in the store`); + } + + if (held.record.type === "MANUAL") { + await this.#completeManualProjection(waitpointId, held.completion ?? completion); + } + + return { + waitpoint: toPrismaWaitpoint(held.record, held.status, held.completion), + blockedRuns: result.watchers.map((watcher) => ({ + taskRunId: watcher.runId, + spanIdToComplete: watcher.spanIdToComplete ?? null, + createdAt: new Date(watcher.createdAt), + })), + }; + } + + async createDateTimeWaitpoint( + params: CreateDateTimeWaitpointParams + ): Promise { + return this.#createStandalone({ + type: "DATETIME", + environmentId: params.environmentId, + projectId: params.projectId, + idempotencyKey: params.idempotencyKey, + idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt, + completedAfter: params.completedAfter, + }); + } + + async createManualWaitpoint(params: CreateManualWaitpointParams): Promise { + const result = await this.#createStandalone({ + type: "MANUAL", + environmentId: params.environmentId, + projectId: params.projectId, + idempotencyKey: params.idempotencyKey, + idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt, + completedAfter: params.timeout, + tags: params.tags, + }); + + // Only the call that actually created the waitpoint writes the projection. A cached + // idempotency hit returns a waitpoint that already has its row, and inserting it again + // violates the primary key. + if (result.kind === "created") { + await this.#writeManualProjection(result.waitpoint); + } + + return result; + } + + async createBatchWaitpoint({ + batchId, + environmentId, + projectId, + }: CreateBatchWaitpointParams): Promise { + const waitpointId = deriveWaitpointIdFromAnchor(batchId, "BATCH"); + if (!waitpointId) { + throw new Error(`Batch ${batchId} is not a run-ops id, so no BATCH waitpoint derives`); + } + + const record = this.#record({ + id: waitpointId, + type: "BATCH", + environmentId, + projectId, + idempotencyKey: batchId, + completedByBatchId: batchId, + }); + + const created = await this.store.createIfAbsent({ record, status: "PENDING" }); + + // The duplicate-batch contract. NX reports the second call, where the legacy arm gets + // a unique-index violation. + if (created.outcome === "exists") { + return null; + } + + return toPrismaWaitpoint(record, "PENDING"); + } + + mintAssociatedWaitpointData({ + projectId, + environmentId, + anchorRunId, + }: { + projectId: string; + environmentId: string; + anchorRunId?: string; + }): AssociatedWaitpointData { + const derived = anchorRunId ? deriveWaitpointIdFromAnchor(anchorRunId, "RUN") : undefined; + if (!derived) { + throw new Error( + `Run ${anchorRunId ?? "(none)"} is not a run-ops id, so no RUN waitpoint derives` + ); + } + + return { + id: derived, + friendlyId: WaitpointId.toFriendlyId(derived), + type: "RUN", + status: "PENDING", + idempotencyKey: nanoid(24), + userProvidedIdempotencyKey: false, + projectId, + environmentId, + }; + } + + /** + * Create-if-absent on the anchor-derived id. + * + * The lock and double-check the legacy arm needs are gone: the id is a pure function of + * the run id, so two racing callers compute the same id and NX settles it. A caller that + * finds it already present takes the existing record, which is what makes the crash + * window between the run commit and this call recoverable by retry. + */ + async createAssociatedWaitpoint({ + runId, + data, + }: { + runId: string; + data: AssociatedWaitpointData; + }): Promise { + const record = this.#record({ + id: data.id, + friendlyId: data.friendlyId, + type: "RUN", + environmentId: data.environmentId, + projectId: data.projectId, + idempotencyKey: data.idempotencyKey, + completedByTaskRunId: runId, + }); + + const created = await this.store.createIfAbsent({ record, status: "PENDING" }); + if (created.outcome === "exists") { + return toPrismaWaitpoint(created.record, created.status, created.completion); + } + + return toPrismaWaitpoint(record, "PENDING"); + } + + async #createStandalone(params: { + type: "DATETIME" | "MANUAL"; + environmentId: string; + projectId: string; + idempotencyKey?: string; + idempotencyKeyExpiresAt?: Date; + completedAfter?: Date; + tags?: string[]; + }): Promise { + const userProvidedIdempotencyKey = params.idempotencyKey !== undefined; + const record = this.#record({ + id: generateWaitpointId(params.type), + type: params.type, + environmentId: params.environmentId, + projectId: params.projectId, + idempotencyKey: params.idempotencyKey ?? nanoid(24), + userProvidedIdempotencyKey, + idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt?.toISOString(), + completedAfter: params.completedAfter?.toISOString(), + tags: params.tags, + }); + + // Without a user key there is nothing to dedupe against, so the reservation round trip + // is skipped entirely rather than reserved against a random key nobody will present. + if (!userProvidedIdempotencyKey) { + await this.store.createIfAbsent({ record, status: "PENDING" }); + return { kind: "created", waitpoint: toPrismaWaitpoint(record, "PENDING") }; + } + + const reserved = await this.store.createWithIdempotencyKey({ + record, + environmentId: params.environmentId, + idempotencyKey: params.idempotencyKey!, + }); + + if (reserved.created) { + return { kind: "created", waitpoint: toPrismaWaitpoint(record, "PENDING") }; + } + + const held = await this.store.readWaitpoint(reserved.waitpointId); + if (!held) { + throw new Error(`Waitpoint ${reserved.waitpointId} won the reservation but is absent`); + } + + return { + kind: "cached", + waitpoint: toPrismaWaitpoint(held.record, held.status, held.completion), + }; + } + + /** + * The MANUAL projection (I6). Written after the store commit, read by the dashboard and + * the token API, and never consulted for coordination. + * + * A failure here must not fail the create: the waitpoint already exists in the store and + * is already coordinating, so throwing would report failure for work that succeeded. + */ + async #writeManualProjection(waitpoint: Waitpoint): Promise { + const [error] = await tryCatch( + this.runStore.createWaitpoint({ + data: { + id: waitpoint.id, + friendlyId: waitpoint.friendlyId, + type: "MANUAL", + status: waitpoint.status, + idempotencyKey: waitpoint.idempotencyKey, + userProvidedIdempotencyKey: waitpoint.userProvidedIdempotencyKey, + idempotencyKeyExpiresAt: waitpoint.idempotencyKeyExpiresAt ?? undefined, + completedAfter: waitpoint.completedAfter ?? undefined, + environmentId: waitpoint.environmentId, + projectId: waitpoint.projectId, + tags: waitpoint.tags, + }, + }) + ); + + if (error) { + this.projectionWriteFailures.add(1); + this.logger.error("failed to write the MANUAL waitpoint projection row", { + waitpointId: waitpoint.id, + error, + }); + } + } + + /** + * Reflect a MANUAL completion onto the projection row. + * + * The token API and the dashboard read status, output and completedAt from this row, so + * leaving it PENDING would report a completed token as still waiting. Best effort, for + * the same reason as the create-time write: the store already completed the waitpoint. + */ + async #completeManualProjection( + waitpointId: string, + completion: WaitpointCompletion + ): Promise { + const output = completion.output; + + const [error] = await tryCatch( + this.runStore.updateManyWaitpoints({ + where: { id: waitpointId }, + data: { + status: "COMPLETED", + completedAt: new Date(completion.completedAt), + output: output ? ("inline" in output ? output.inline : output.ref) : null, + outputType: completion.outputType, + outputIsError: completion.outputIsError, + }, + }) + ); + + if (error) { + this.projectionWriteFailures.add(1); + this.logger.error("failed to complete the MANUAL waitpoint projection row", { + waitpointId, + error, + }); + } + } + + #record(params: { + id: string; + friendlyId?: string; + type: WaitpointRecordInput["type"]; + environmentId: string; + projectId: string; + idempotencyKey: string; + userProvidedIdempotencyKey?: boolean; + idempotencyKeyExpiresAt?: string; + completedAfter?: string; + completedByTaskRunId?: string; + completedByBatchId?: string; + tags?: string[]; + }): WaitpointRecordInput { + const now = new Date().toISOString(); + + return { + id: params.id, + friendlyId: params.friendlyId ?? WaitpointId.toFriendlyId(params.id), + type: params.type, + environmentId: params.environmentId, + projectId: params.projectId, + createdAt: now, + updatedAt: now, + userProvidedIdempotencyKey: params.userProvidedIdempotencyKey ?? false, + tags: params.tags ?? [], + idempotencyKey: params.idempotencyKey, + idempotencyKeyExpiresAt: params.idempotencyKeyExpiresAt, + completedAfter: params.completedAfter, + completedByTaskRunId: params.completedByTaskRunId, + completedByBatchId: params.completedByBatchId, + }; + } +} diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/storeCoordinator.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeCoordinator.ts index 42be33724f6..164d0caf15a 100644 --- a/internal-packages/run-engine/src/engine/waitpointCoordinator/storeCoordinator.ts +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/storeCoordinator.ts @@ -506,6 +506,35 @@ export class WaitpointStoreCoordinator { return { pendingIds, deliveredIds, edges }; } + /** + * Read one waitpoint's three parts, or undefined when the store does not hold it. + * + * Single key, so no script and no #call guard: nothing here can span two slots. The + * seam needs this because its return types are the Postgres row shape, and only the + * immutable record carries the columns that shape requires. + */ + async readWaitpoint(waitpointId: string): Promise< + | { + record: WaitpointRecordInput; + status: WaitpointStatus; + completion?: WaitpointCompletion; + } + | undefined + > { + const fields = await this.redis.hmget(waitpointKeys(waitpointId).record, "r", "status", "c"); + + const record = parseJson(fields[0] ?? undefined); + if (!record) { + return undefined; + } + + return { + record, + status: fields[1] === "COMPLETED" ? "COMPLETED" : "PENDING", + completion: parseJson(fields[2] ?? undefined), + }; + } + /** * Source the envelope fields for a run's COMPLETED waitpoints. * diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/types.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/types.ts index 026e7deb8b5..2f1f4d2af02 100644 --- a/internal-packages/run-engine/src/engine/waitpointCoordinator/types.ts +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/types.ts @@ -25,11 +25,18 @@ export type WaitpointCoordinator = { complete(params: CompleteParams): Promise; createDateTimeWaitpoint(params: CreateDateTimeWaitpointParams): Promise; createManualWaitpoint(params: CreateManualWaitpointParams): Promise; + createBatchWaitpoint(params: CreateBatchWaitpointParams): Promise; mintAssociatedWaitpointData(params: { projectId: string; environmentId: string; - /** This write skips the router's stamp check. */ + /** + * The run this waitpoint belongs to. This write skips the router's stamp check. A store + * arm also derives the waitpoint id from the run's own id body, so the derivation is a + * pure function of the anchor and needs no lock. + */ anchorRunId: string; + /** Which arm mints it. Absent means legacy, which is what every existing caller wants. */ + mintKind?: WaitpointMintKind; }): AssociatedWaitpointData; createAssociatedWaitpoint(params: { runId: string; @@ -37,6 +44,23 @@ export type WaitpointCoordinator = { }): Promise; }; +/** + * Which coordinator mints a NEW waitpoint. Structurally identical to the webapp's own + * WaitpointMintKind; re-declared because the engine never imports from the webapp. + * + * Read at the mint and never again: every later operation routes by the minted id's shape. + */ +export type WaitpointMintKind = "legacy" | "store"; + +export type CreateBatchWaitpointParams = { + batchId: string; + environmentId: string; + projectId: string; + mintKind: WaitpointMintKind; + /** Legacy arm only: the create may join a caller transaction. A store arm ignores it. */ + tx?: PrismaClientOrTransaction; +}; + export type ReadCompletionEnvelopesParams = { runId: string; /** The DISTINCT completed waitpoint ids to source. Result order is not meaningful. */ @@ -113,7 +137,15 @@ export type RegisterBlocksParams = { * The lockless variant writes the edge and does not count. Two methods rather than * one method with a flag, so "the batch path issues no extra query" is structural. */ -export type RegisterBlocksLocklessParams = Omit; +export type RegisterBlocksLocklessParams = Omit & { + /** + * The parent's BATCH waitpoint id. A store arm asserts it is present and PENDING on the + * run's shard before writing any item edge, so the run's pending set can never be + * momentarily empty mid-absorb. Neither TLA+ campaign models this, so the assertion is + * the only protection. A legacy arm ignores it. + */ + batchWaitpointId?: string; +}; export type CompleteParams = { waitpointId: string; @@ -146,6 +178,7 @@ export type CreateWaitpointResult = | { kind: "created"; waitpoint: Waitpoint }; export type CreateDateTimeWaitpointParams = { + mintKind: WaitpointMintKind; /** * Co-locates the waitpoint with this run's DB. There is deliberately no standalone arm: omitting * it on a gen-2 environment lands the row on a gen-1 store, silently. A standalone caller needs @@ -160,6 +193,7 @@ export type CreateDateTimeWaitpointParams = { }; export type CreateManualWaitpointParams = { + mintKind: WaitpointMintKind; runId?: string; environmentId: string; projectId: string; diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointMintCatalog.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointMintCatalog.ts index 7f5df203b65..14f5be4d747 100644 --- a/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointMintCatalog.ts +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointMintCatalog.ts @@ -13,8 +13,8 @@ export type WaitpointMintSite = { const COORDINATOR = "internal-packages/run-engine/src/engine/waitpointCoordinator/legacyPostgresCoordinator.ts"; -const ENGINE = "internal-packages/run-engine/src/engine/index.ts"; const RUN_STORE = "internal-packages/run-store/src/PostgresRunStore.ts"; +const STORE_ARM = "internal-packages/run-engine/src/engine/waitpointCoordinator/storeArm.ts"; export const WAITPOINT_MINT_SITES: readonly WaitpointMintSite[] = [ { @@ -46,11 +46,21 @@ export const WAITPOINT_MINT_SITES: readonly WaitpointMintSite[] = [ symbol: "createAssociatedWaitpoint", }, { - id: "engine.batch", + id: "coordinator.batch", mints: ["mintWaitpointIdFor(batchId)"], type: "BATCH", - site: ENGINE, - symbol: "blockRunWithCreatedBatch", + site: COORDINATOR, + symbol: "createBatchWaitpoint", + }, + // The store arm's own ids are minted in the store's keyspace, not stamped for the router, + // so it declares no mint expression. The row it does write to Postgres is the MANUAL + // projection, which is display state and never read back for coordination. + { + id: "storeArm.manualProjection", + mints: [], + type: "MANUAL", + site: STORE_ARM, + symbol: "#writeManualProjection", }, // These bypass the routing store's stamp check, so a new writer here must be seen. { diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.test.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.test.ts new file mode 100644 index 00000000000..225f5694ee7 --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { WaitpointRecordInput } from "./storeCoordinator.js"; +import { toPrismaWaitpoint } from "./waitpointShape.js"; + +const record: WaitpointRecordInput = { + id: "abcdefghijklmnopqrstuvwxmw", + friendlyId: "waitpoint_abcdefghijklmnopqrstuvwxmw", + type: "MANUAL", + environmentId: "env_1", + projectId: "proj_1", + createdAt: "2026-08-26T10:00:00.000Z", + updatedAt: "2026-08-26T10:00:01.000Z", + userProvidedIdempotencyKey: true, + tags: ["alpha", "beta"], + idempotencyKey: "user-key", +}; + +describe("toPrismaWaitpoint", () => { + it("fills every non-null column on a PENDING waitpoint", () => { + const waitpoint = toPrismaWaitpoint(record, "PENDING"); + + expect(waitpoint.id).toBe(record.id); + expect(waitpoint.friendlyId).toBe(record.friendlyId); + expect(waitpoint.type).toBe("MANUAL"); + expect(waitpoint.status).toBe("PENDING"); + expect(waitpoint.idempotencyKey).toBe("user-key"); + expect(waitpoint.userProvidedIdempotencyKey).toBe(true); + expect(waitpoint.projectId).toBe("proj_1"); + expect(waitpoint.environmentId).toBe("env_1"); + expect(waitpoint.tags).toEqual(["alpha", "beta"]); + expect(waitpoint.createdAt).toEqual(new Date("2026-08-26T10:00:00.000Z")); + expect(waitpoint.updatedAt).toEqual(new Date("2026-08-26T10:00:01.000Z")); + + // The columns with database defaults, which a consumer reads unconditionally. + expect(waitpoint.outputType).toBe("application/json"); + expect(waitpoint.outputIsError).toBe(false); + + // Nullable columns that must be null rather than undefined: a consumer distinguishes + // "no value" from "field missing", and `inactiveIdempotencyKey` is not ported at all. + expect(waitpoint.completedAt).toBeNull(); + expect(waitpoint.output).toBeNull(); + expect(waitpoint.inactiveIdempotencyKey).toBeNull(); + expect(waitpoint.idempotencyKeyExpiresAt).toBeNull(); + expect(waitpoint.completedByTaskRunId).toBeNull(); + expect(waitpoint.completedByBatchId).toBeNull(); + expect(waitpoint.completedAfter).toBeNull(); + }); + + it("carries an inline completion onto a COMPLETED waitpoint", () => { + const waitpoint = toPrismaWaitpoint(record, "COMPLETED", { + completedAt: "2026-08-26T11:00:00.000Z", + outputType: "application/json", + outputIsError: true, + output: { inline: '{"boom":true}' }, + }); + + expect(waitpoint.status).toBe("COMPLETED"); + expect(waitpoint.completedAt).toEqual(new Date("2026-08-26T11:00:00.000Z")); + expect(waitpoint.output).toBe('{"boom":true}'); + expect(waitpoint.outputType).toBe("application/json"); + expect(waitpoint.outputIsError).toBe(true); + }); + + it("carries an offloaded reference in the output column, as the legacy row does", () => { + const waitpoint = toPrismaWaitpoint(record, "COMPLETED", { + completedAt: "2026-08-26T11:00:00.000Z", + outputType: "application/store", + outputIsError: false, + output: { ref: "waitpoints/abc/output.json" }, + }); + + expect(waitpoint.output).toBe("waitpoints/abc/output.json"); + expect(waitpoint.outputType).toBe("application/store"); + }); + + it("leaves output null when the completion carries none", () => { + // A BATCH completion, and the deriveFromRun case: the value is re-derived at read + // time and is never copied onto the row. + const waitpoint = toPrismaWaitpoint({ ...record, type: "BATCH" }, "COMPLETED", { + completedAt: "2026-08-26T11:00:00.000Z", + outputType: "application/json", + outputIsError: false, + output: null, + }); + + expect(waitpoint.status).toBe("COMPLETED"); + expect(waitpoint.output).toBeNull(); + }); + + it("maps the optional anchor and timing columns when the record carries them", () => { + const waitpoint = toPrismaWaitpoint( + { + ...record, + type: "RUN", + completedByTaskRunId: "run_1", + completedByBatchId: "batch_1", + completedAfter: "2026-08-27T00:00:00.000Z", + idempotencyKeyExpiresAt: "2026-08-28T00:00:00.000Z", + }, + "PENDING" + ); + + expect(waitpoint.completedByTaskRunId).toBe("run_1"); + expect(waitpoint.completedByBatchId).toBe("batch_1"); + expect(waitpoint.completedAfter).toEqual(new Date("2026-08-27T00:00:00.000Z")); + expect(waitpoint.idempotencyKeyExpiresAt).toEqual(new Date("2026-08-28T00:00:00.000Z")); + }); + + it("throws when the record carries no idempotency key", () => { + // The column is non-null and participates in the (environmentId, idempotencyKey) + // unique index, so inventing a value here could collide. The arm always mints one; + // an absent key means the arm has a defect, and it must surface as one. + const { idempotencyKey, ...withoutKey } = record; + + expect(() => toPrismaWaitpoint(withoutKey, "PENDING")).toThrow(/idempotency key/i); + }); +}); diff --git a/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.ts b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.ts new file mode 100644 index 00000000000..3ab9da2ed38 --- /dev/null +++ b/internal-packages/run-engine/src/engine/waitpointCoordinator/waitpointShape.ts @@ -0,0 +1,59 @@ +import type { Waitpoint } from "@trigger.dev/database"; +import type { + WaitpointCompletion, + WaitpointRecordInput, + WaitpointStatus, +} from "./storeCoordinator.js"; + +/** + * Present a store-resident waitpoint as the Postgres row shape the seam returns. + * + * A store waitpoint has no row, but `WaitpointCoordinator`'s return types are the Prisma + * `Waitpoint`, and callers reach for its columns directly. Every column is listed + * explicitly rather than spread: a missing non-null column surfaces as `undefined` far + * from here, in a consumer that had no reason to guard. + */ +export function toPrismaWaitpoint( + record: WaitpointRecordInput, + status: WaitpointStatus, + completion?: WaitpointCompletion +): Waitpoint { + if (!record.idempotencyKey) { + // Non-null in the schema, and half of the (environmentId, idempotencyKey) unique + // index, so a synthesized value could collide with a real one. Every arm mints one. + throw new Error(`Waitpoint ${record.id} has no idempotency key`); + } + + const output = completion?.output; + + return { + id: record.id, + friendlyId: record.friendlyId, + type: record.type, + status, + completedAt: completion ? new Date(completion.completedAt) : null, + idempotencyKey: record.idempotencyKey, + userProvidedIdempotencyKey: record.userProvidedIdempotencyKey, + idempotencyKeyExpiresAt: optionalDate(record.idempotencyKeyExpiresAt), + // Not ported: clearing an idempotency key is a legacy debounce mechanism the store + // replaces with key expiry. + inactiveIdempotencyKey: null, + completedByTaskRunId: record.completedByTaskRunId ?? null, + completedAfter: optionalDate(record.completedAfter), + completedByBatchId: record.completedByBatchId ?? null, + // An offloaded reference rides the output column exactly as it does on a legacy row, + // with outputType naming it. A null output is re-derived at read time, never copied. + output: output ? ("inline" in output ? output.inline : output.ref) : null, + outputType: completion?.outputType ?? "application/json", + outputIsError: completion?.outputIsError ?? false, + projectId: record.projectId, + environmentId: record.environmentId, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + tags: record.tags, + }; +} + +function optionalDate(value: string | undefined): Date | null { + return value ? new Date(value) : null; +} diff --git a/internal-packages/run-store/src/PostgresRunStore.completedWaitpointResidency.test.ts b/internal-packages/run-store/src/PostgresRunStore.completedWaitpointResidency.test.ts new file mode 100644 index 00000000000..6d54bfb5006 --- /dev/null +++ b/internal-packages/run-store/src/PostgresRunStore.completedWaitpointResidency.test.ts @@ -0,0 +1,135 @@ +import { postgresTest } from "@internal/testcontainers"; +import { generateWaitpointId } from "@trigger.dev/core/v3/isomorphic"; +import { describe, expect } from "vitest"; +import type { PrismaClient } from "@trigger.dev/database"; +import { PostgresRunStore } from "./PostgresRunStore.js"; +import type { CreateRunInput } from "./types.js"; + +// The _completedWaitpoints join has a foreign key to "Waitpoint". A waitpoint that lives +// outside Postgres has no row there, and its snapshot link travels with the snapshot entry +// instead. Offering such an id to the insert fails the constraint, and because the insert +// shares the resume's transaction it takes the whole resume down: the run then never +// continues. So those ids have to be dropped before the statement runs, not after it fails. +describe("createExecutionSnapshot completed-waitpoint links", () => { + postgresTest( + "skips a waitpoint id that has no Postgres row instead of violating the foreign key", + async ({ prisma }) => { + const { organization, project, environment } = await seedEnvironment(prisma); + const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); + const runId = "run_mixed_links"; + + await store.createRun( + buildCreateRunInput({ + runId, + organizationId: organization.id, + projectId: project.id, + runtimeEnvironmentId: environment.id, + }) + ); + + const legacyWaitpoint = await prisma.waitpoint.create({ + data: { + id: "waitpoint_legacy_link", + friendlyId: "waitpoint_legacy_link", + type: "MANUAL", + status: "COMPLETED", + idempotencyKey: "legacy-link", + userProvidedIdempotencyKey: false, + environmentId: environment.id, + projectId: project.id, + }, + }); + const storeWaitpointId = generateWaitpointId("MANUAL"); + + const snapshot = await store.createExecutionSnapshot( + { + run: { id: runId, status: "PENDING", attemptNumber: 1 }, + snapshot: { executionStatus: "EXECUTING", description: "resumed" }, + environmentId: environment.id, + environmentType: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + completedWaitpoints: [{ id: legacyWaitpoint.id }, { id: storeWaitpointId }], + }, + prisma + ); + + const links = await prisma.$queryRaw<{ B: string }[]>` + SELECT "B" FROM "_completedWaitpoints" WHERE "A" = ${snapshot.id}`; + + expect(links.map((link) => link.B)).toEqual([legacyWaitpoint.id]); + } + ); +}); + +async function seedEnvironment(prisma: PrismaClient) { + const organization = await prisma.organization.create({ + data: { + title: "Test Organization", + slug: "test-organization", + }, + }); + + const project = await prisma.project.create({ + data: { + name: "Test Project", + slug: "test-project", + externalRef: "proj_1234", + organizationId: organization.id, + }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + type: "DEVELOPMENT", + slug: "dev", + projectId: project.id, + organizationId: organization.id, + apiKey: "tr_dev_apikey", + pkApiKey: "pk_dev_apikey", + shortcode: "short_code", + }, + }); + + return { organization, project, environment }; +} + +function buildCreateRunInput(params: { + runId: string; + organizationId: string; + projectId: string; + runtimeEnvironmentId: string; +}): CreateRunInput { + return { + data: { + id: params.runId, + engine: "V2", + status: "PENDING", + friendlyId: "run_friendly_1", + runtimeEnvironmentId: params.runtimeEnvironmentId, + environmentType: "DEVELOPMENT", + organizationId: params.organizationId, + projectId: params.projectId, + taskIdentifier: "my-task", + payload: "{}", + payloadType: "application/json", + traceContext: {}, + traceId: "trace_1", + spanId: "span_1", + queue: "task/my-task", + isTest: false, + taskEventStore: "taskEvent", + depth: 0, + }, + snapshot: { + engine: "V2", + executionStatus: "RUN_CREATED", + description: "Run was created", + runStatus: "PENDING", + environmentId: params.runtimeEnvironmentId, + environmentType: "DEVELOPMENT", + projectId: params.projectId, + organizationId: params.organizationId, + }, + }; +} diff --git a/internal-packages/run-store/src/PostgresRunStore.ts b/internal-packages/run-store/src/PostgresRunStore.ts index 22dc2f90c44..4c0a55cdd02 100644 --- a/internal-packages/run-store/src/PostgresRunStore.ts +++ b/internal-packages/run-store/src/PostgresRunStore.ts @@ -30,6 +30,7 @@ import type { TaskRunWithWaitpoint, } from "./types.js"; import type { TaskRunError } from "@trigger.dev/core/v3/schemas"; +import { parseWaitpointId } from "@trigger.dev/core/v3/isomorphic"; import type { ShardKey } from "@trigger.dev/core/v3/isomorphic"; // Loose delegate method shape: each generated client types delegate methods as @@ -1244,14 +1245,20 @@ export class PostgresRunStore implements RunStore { snapshotId: string, waitpointIds: string[] ): Promise { - if (waitpointIds.length === 0) { + // This join has a foreign key to "Waitpoint", so it can only carry ids that have a row + // there. A waitpoint held outside Postgres has none, and its snapshot link travels with + // the snapshot entry instead. Inserting it here fails the constraint and takes the + // resume down with it, so those ids are dropped rather than offered to the insert. + const legacyIds = waitpointIds.filter((id) => parseWaitpointId(id).format === "legacy"); + + if (legacyIds.length === 0) { return; } await client.$executeRaw` INSERT INTO "_completedWaitpoints" ("A", "B") SELECT ${snapshotId}, w.id - FROM unnest(${waitpointIds}::text[]) AS w(id) + FROM unnest(${legacyIds}::text[]) AS w(id) ON CONFLICT DO NOTHING`; } diff --git a/packages/build/src/package.json b/packages/build/src/package.json deleted file mode 100644 index 3dbc1ca591c..00000000000 --- a/packages/build/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -}