84f3e1b39c
## What Routes the webapp write path through the run-ops split seam: trigger/batch minting, idempotency-key resolution, and the run-lifecycle services now determine residency and dispatch writes to the correct store. - **Trigger & batch** (`runEngine/services/triggerTask.server.ts`, `batchTrigger.server.ts`, `createBatch.server.ts`, `streamBatchItems.server.ts`, `v3/services/batchTriggerV3.server.ts`): mint ids with the run-ops-aware minting and route creation/streaming through the store; batch children inherit the parent's residency. - **Idempotency** (`runEngine/concerns/idempotencyKeys.server.ts` + new `idempotencyResidency.server.ts`): idempotency-key lookup/dedup is residency-aware so a keyed retrigger resolves against the store that owns the original run. - **Run lifecycle services** (`createCheckpoint`, `createTaskRunAttempt`, `enqueueDelayedRun`, `expireEnqueuedRun`, `finalizeTaskRun`, `resumeBatchRun`, `cancelDevSessionRuns`, `executeTasksWaitingForDeploy`, `triggerFailedTask`): resolve their target run through the store rather than a fixed client. - **Reads that fan out from writes** (`runsRepository` + `clickhouseRunsRepository`, `BulkActionV2` + batch read-through, realtime `sessions`/`runReader`, alerts `deliverAlert`/`performTaskRunAlerts`): route through the read-through resolver. - `9535ae63d` — resolves the parent run through an injectable run store in `TriggerFailedTaskService`. - `bf8f7c881` — drops the "known-migrated" concept from write-path and read repos; residency is id-shape only. - `515b897ea` — self-defaults `resolveWaitpointThroughReadThrough` to the safe run-ops clients. ## Why PR6 of the run-ops split stack. This is the write-path counterpart to the read foundation in the previous PRs: with it in place, both reads and writes route through the seam. Additive when the split is disabled (id-shape resolution collapses to the control-plane client); behavior-changing on the minting, idempotency, and lifecycle paths when enabled. ## Tests Large new/expanded vitest suite under `apps/webapp/test/` and colocated service tests: trigger-task and batch-trigger store routing, residency inheritance, idempotency dedup residency + legacy-authority, bulk-action read routing, cancel-dev-session routing, alerts store routing, runs-repository read-through, realtime session/run-reader read-through and stream-registration routing, and the waitpoint read-through default. Testcontainers-backed; no mocks. ## Notes Draft, **stacked on #4117** (`runops/pr05-webapp-foundation`). Review that first; this diff is against it. Server-change / changeset note to be added at stack-assembly time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
6.8 KiB
TypeScript
160 lines
6.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// Stub `~/db.server` before importing the concern — the real module
|
|
// eagerly calls `prisma.$connect()` at singleton construction, which
|
|
// would fail without a database. The concern under test receives its
|
|
// prisma via the constructor, so these empty stubs are never used by the
|
|
// tested path; the run-ops singletons only satisfy the concern's static
|
|
// imports (vitest validates every named import against the mock).
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: {},
|
|
$replica: {},
|
|
runOpsNewPrisma: {},
|
|
runOpsLegacyPrisma: {},
|
|
runOpsNewReplica: {},
|
|
runOpsLegacyReplica: {},
|
|
}));
|
|
|
|
// The IdempotencyKeyConcern resolves the pre-gate claim through the
|
|
// global mollifier buffer (`getMollifierBuffer`), shared by both
|
|
// `claimOrAwait` and `findBufferedRunWithIdempotency`. Control it via a
|
|
// hoisted handle so each test can script the claim/lookup responses.
|
|
const h = vi.hoisted(() => ({ buffer: null as unknown, orgFlag: true }));
|
|
vi.mock("~/v3/mollifier/mollifierBuffer.server", () => ({
|
|
getMollifierBuffer: () => h.buffer,
|
|
}));
|
|
// Stub `mollifierGate.server` so loading the concern doesn't drag in
|
|
// `env.server` (which fails to parse without a populated environment in
|
|
// CI). The concern only uses `makeResolveMollifierFlag` to gate the
|
|
// claim; tests flip `h.orgFlag` to cover both opted-in and opted-out
|
|
// orgs without touching real env or feature-flag wiring.
|
|
vi.mock("~/v3/mollifier/mollifierGate.server", () => ({
|
|
makeResolveMollifierFlag: () => async () => h.orgFlag,
|
|
}));
|
|
// Pin the idempotency dedup routing to the injected fake prisma: split OFF
|
|
// makes resolveIdempotencyDedupClient return the concern's constructor client,
|
|
// so these tests exercise claim resolution deterministically regardless of the
|
|
// ambient RUN_OPS_SPLIT_ENABLED (the split path routes to the empty runOps mocks).
|
|
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({
|
|
isSplitEnabled: async () => false,
|
|
}));
|
|
|
|
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
|
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
|
|
import type { TriggerTaskRequest } from "~/runEngine/types";
|
|
|
|
function makeConcern(prisma: { findFirst: () => Promise<unknown> }) {
|
|
return new IdempotencyKeyConcern(
|
|
{ taskRun: { findFirst: prisma.findFirst } } as never,
|
|
{} as never, // engine — unused on this path
|
|
{} as never // traceEventConcern — unused on this path
|
|
);
|
|
}
|
|
|
|
function makeRequest(): TriggerTaskRequest {
|
|
return {
|
|
taskId: "my-task",
|
|
environment: {
|
|
id: "env_a",
|
|
organizationId: "org_1",
|
|
// The pre-gate claim is gated by the per-org mollifier flag
|
|
// (mirroring evaluateGate's gating) so non-opted-in orgs don't pay
|
|
// the Redis SETNX. Tests covering the claim path must opt this
|
|
// fake org in, otherwise the concern skips claimOrAwait entirely
|
|
// and the resolution branches under test never run.
|
|
organization: { featureFlags: { mollifierEnabled: true } },
|
|
},
|
|
options: {},
|
|
body: { options: { idempotencyKey: "k-1" } },
|
|
} as unknown as TriggerTaskRequest;
|
|
}
|
|
|
|
describe("IdempotencyKeyConcern · claim resolution", () => {
|
|
it("resolved-but-unfindable falls through to a fresh trigger (no cached run, no claim held)", async () => {
|
|
// The claim slot holds a runId that is gone from both stores: the PG
|
|
// findFirst misses and the buffer lookup misses. Regression guard for
|
|
// the resolved-but-unfindable terminal case — the concern must fall
|
|
// through to a fresh trigger rather than throw, hand back a bogus
|
|
// cached run, or claim ownership it doesn't hold.
|
|
const lookupIdempotency = vi.fn(async () => null);
|
|
h.buffer = {
|
|
claimIdempotency: vi.fn(async () => ({ kind: "resolved", runId: "run_gone" })),
|
|
lookupIdempotency,
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const findFirst = vi.fn(async () => null); // PG misses on every call
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
|
|
expect(result.isCached).toBe(false);
|
|
if (result.isCached === false) {
|
|
// No claim held — we resolved someone else's (stale) claim, we did
|
|
// not win one. The caller must NOT publish/release on our behalf.
|
|
expect(result.claim).toBeUndefined();
|
|
expect(result.idempotencyKey).toBe("k-1");
|
|
}
|
|
// We attempted the buffer fallback before giving up.
|
|
expect(lookupIdempotency).toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolved-and-findable returns the existing run as a cached hit", async () => {
|
|
// Guard the happy resolved path: when the claimed runId IS findable
|
|
// (writer-side PG), the fall-through change must not swallow it.
|
|
h.buffer = {
|
|
claimIdempotency: vi.fn(async () => ({ kind: "resolved", runId: "run_winner" })),
|
|
lookupIdempotency: vi.fn(async () => null),
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const winner = { id: "run_winner", friendlyId: "run_winner" };
|
|
// First findFirst (initial existingRun check) misses so we enter the
|
|
// claim path; the second (writer-side re-resolve) finds the winner.
|
|
let calls = 0;
|
|
const findFirst = vi.fn(async () => {
|
|
calls += 1;
|
|
return calls >= 2 ? winner : null;
|
|
});
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
|
|
expect(result.isCached).toBe(true);
|
|
if (result.isCached === true) {
|
|
expect(result.run).toBe(winner);
|
|
}
|
|
});
|
|
|
|
it("non-opted-in org skips claimOrAwait entirely (no buffer round-trip, no claim held)", async () => {
|
|
// Regression guard for the per-org gating that keeps the claim's
|
|
// Redis SETNX off the hot path for orgs that haven't opted into the
|
|
// mollifier — even when `TRIGGER_MOLLIFIER_ENABLED=1` globally and
|
|
// the buffer singleton exists. The concern should NOT touch
|
|
// `claimIdempotency` for these orgs; PG's unique constraint already
|
|
// deduplicates same-key races on the pass-through path.
|
|
h.orgFlag = false;
|
|
const claimIdempotency = vi.fn(async () => ({ kind: "claimed" as const }));
|
|
const lookupIdempotency = vi.fn(async () => null);
|
|
h.buffer = {
|
|
claimIdempotency,
|
|
lookupIdempotency,
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const findFirst = vi.fn(async () => null);
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
try {
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
expect(result.isCached).toBe(false);
|
|
if (result.isCached === false) {
|
|
// No claim returned — the caller must NOT publish/release.
|
|
expect(result.claim).toBeUndefined();
|
|
expect(result.idempotencyKey).toBe("k-1");
|
|
}
|
|
// The headline guarantee: zero Redis claim activity for this org.
|
|
expect(claimIdempotency).not.toHaveBeenCalled();
|
|
} finally {
|
|
h.orgFlag = true; // restore for any later tests in this file
|
|
}
|
|
});
|
|
});
|