chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { containerTest } from "@internal/testcontainers";
|
||||
import type {
|
||||
Organization,
|
||||
PrismaClient,
|
||||
Project,
|
||||
RuntimeEnvironment,
|
||||
} from "@trigger.dev/database";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { expect, vi } from "vitest";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { seedTestEnvironment } from "../helpers/seedTestEnvironment";
|
||||
|
||||
// Neutralize the db.server singleton so importing the presenter (via BasePresenter) does not try
|
||||
// to connect to the env database; the test uses the injected testcontainer prisma for all reads.
|
||||
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
|
||||
|
||||
function authEnv(
|
||||
environment: RuntimeEnvironment,
|
||||
project: Project,
|
||||
organization: Organization
|
||||
): AuthenticatedEnvironment {
|
||||
return { ...environment, project, organization, orgMember: null } as AuthenticatedEnvironment;
|
||||
}
|
||||
|
||||
type SeedContext = {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
backgroundWorkerId: string;
|
||||
backgroundWorkerTaskId: string;
|
||||
queueId: string;
|
||||
};
|
||||
|
||||
async function seedWorker(
|
||||
prisma: PrismaClient,
|
||||
ctx: Omit<SeedContext, "backgroundWorkerId" | "backgroundWorkerTaskId" | "queueId">
|
||||
) {
|
||||
const queue = await prisma.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: `queue_${idGenerator()}`,
|
||||
name: "task/test-task",
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${idGenerator()}`,
|
||||
contentHash: "hash",
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
version: "20240101.1",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
const task = await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${idGenerator()}`,
|
||||
slug: "test-task",
|
||||
filePath: "src/test.ts",
|
||||
exportName: "testTask",
|
||||
workerId: worker.id,
|
||||
projectId: ctx.projectId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
return { queueId: queue.id, backgroundWorkerId: worker.id, backgroundWorkerTaskId: task.id };
|
||||
}
|
||||
|
||||
async function seedRunWithAttempt(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
opts: {
|
||||
status: "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS" | "CANCELED" | "EXECUTING";
|
||||
attempt?: {
|
||||
status: "COMPLETED" | "FAILED";
|
||||
output?: string;
|
||||
outputType?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const runInternalId = idGenerator();
|
||||
const run = await prisma.taskRun.create({
|
||||
data: {
|
||||
id: runInternalId,
|
||||
friendlyId: `run_${runInternalId}`,
|
||||
taskIdentifier: "test-task",
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
traceId: idGenerator(),
|
||||
spanId: idGenerator(),
|
||||
queue: "task/test-task",
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
status: opts.status,
|
||||
},
|
||||
});
|
||||
|
||||
if (opts.attempt) {
|
||||
await prisma.taskRunAttempt.create({
|
||||
data: {
|
||||
friendlyId: `attempt_${idGenerator()}`,
|
||||
taskRunId: run.id,
|
||||
backgroundWorkerId: ctx.backgroundWorkerId,
|
||||
backgroundWorkerTaskId: ctx.backgroundWorkerTaskId,
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
queueId: ctx.queueId,
|
||||
status: opts.attempt.status,
|
||||
output: opts.attempt.output,
|
||||
outputType: opts.attempt.outputType ?? "application/json",
|
||||
error: opts.attempt.error as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
containerTest(
|
||||
"ApiBatchResultsPresenter returns ordered results matching pre-decompose behavior",
|
||||
async ({ prisma }) => {
|
||||
const { environment, project, organization } = await seedTestEnvironment(prisma);
|
||||
const worker = await seedWorker(prisma, {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
const ctx: SeedContext = {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
...worker,
|
||||
};
|
||||
|
||||
// A successful run, a failed run, and an executing run (no terminal attempt → undefined).
|
||||
const successRun = await seedRunWithAttempt(prisma, ctx, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"hello"', outputType: "application/json" },
|
||||
});
|
||||
const failedRun = await seedRunWithAttempt(prisma, ctx, {
|
||||
status: "COMPLETED_WITH_ERRORS",
|
||||
attempt: {
|
||||
status: "FAILED",
|
||||
error: { type: "BUILT_IN_ERROR", name: "Error", message: "boom", stackTrace: "boom" },
|
||||
},
|
||||
});
|
||||
const executingRun = await seedRunWithAttempt(prisma, ctx, {
|
||||
status: "EXECUTING",
|
||||
});
|
||||
|
||||
const batchInternalId = idGenerator();
|
||||
const batchFriendlyId = `batch_${batchInternalId}`;
|
||||
await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id: batchInternalId,
|
||||
friendlyId: batchFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Items inserted in a deterministic order: success, failed, executing.
|
||||
for (const run of [successRun, failedRun, executingRun]) {
|
||||
await prisma.batchTaskRunItem.create({
|
||||
data: {
|
||||
batchTaskRunId: batchInternalId,
|
||||
taskRunId: run.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(
|
||||
batchFriendlyId,
|
||||
authEnv(environment, project, organization)
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(batchFriendlyId);
|
||||
|
||||
// executing run yields no execution result → filtered out. Order preserved: success then failed.
|
||||
expect(result?.items).toHaveLength(2);
|
||||
|
||||
const [first, second] = result!.items;
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.id).toBe(successRun.friendlyId);
|
||||
if (first.ok) {
|
||||
expect(first.output).toBe('"hello"');
|
||||
expect(first.taskIdentifier).toBe("test-task");
|
||||
}
|
||||
|
||||
expect(second.ok).toBe(false);
|
||||
expect(second.id).toBe(failedRun.friendlyId);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"ApiBatchResultsPresenter filters runs without an execution result but keeps order",
|
||||
async ({ prisma }) => {
|
||||
const { environment, project, organization } = await seedTestEnvironment(prisma);
|
||||
const worker = await seedWorker(prisma, {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
const ctx: SeedContext = {
|
||||
environmentId: environment.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
...worker,
|
||||
};
|
||||
|
||||
// Pending run → executionResultForTaskRun returns undefined → filtered out, like the
|
||||
// pre-decompose code did via `.filter(Boolean)`.
|
||||
const pendingRun = await seedRunWithAttempt(prisma, ctx, { status: "EXECUTING" });
|
||||
const successRun = await seedRunWithAttempt(prisma, ctx, {
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
attempt: { status: "COMPLETED", output: '"ok"', outputType: "application/json" },
|
||||
});
|
||||
|
||||
const batchInternalId = idGenerator();
|
||||
const batchFriendlyId = `batch_${batchInternalId}`;
|
||||
await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id: batchInternalId,
|
||||
friendlyId: batchFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
// pending first, success second — only the success result should survive, in order.
|
||||
for (const run of [pendingRun, successRun]) {
|
||||
await prisma.batchTaskRunItem.create({
|
||||
data: { batchTaskRunId: batchInternalId, taskRunId: run.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(
|
||||
batchFriendlyId,
|
||||
authEnv(environment, project, organization)
|
||||
);
|
||||
|
||||
expect(result?.items).toHaveLength(1);
|
||||
expect(result?.items[0]?.id).toBe(successRun.friendlyId);
|
||||
}
|
||||
);
|
||||
|
||||
containerTest("ApiBatchResultsPresenter short-circuits an empty batch", async ({ prisma }) => {
|
||||
const { environment, project, organization } = await seedTestEnvironment(prisma);
|
||||
|
||||
const batchInternalId = idGenerator();
|
||||
const batchFriendlyId = `batch_${batchInternalId}`;
|
||||
await prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id: batchInternalId,
|
||||
friendlyId: batchFriendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Pass the testcontainer prisma as both the primary and the replica: the passthrough path
|
||||
// reads the batch row off the replica and the member runs off the primary (via the run store).
|
||||
const presenter = new ApiBatchResultsPresenter(prisma, prisma);
|
||||
const result = await presenter.call(batchFriendlyId, authEnv(environment, project, organization));
|
||||
|
||||
expect(result).toEqual({ id: batchFriendlyId, items: [] });
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPLIT-NEUTRAL VERIFICATION: getActivity reads ClickHouse
|
||||
// (task_runs_v2) ONLY and never touches the run-ops Prisma client. A
|
||||
// heterogeneous legacy/new Postgres fixture is deliberately not applicable
|
||||
// here — there is no run-ops Postgres read to validate cross-version. The
|
||||
// throwing Proxy passed as `replica` proves it: any access throws.
|
||||
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { ClickHouse, type TaskRunV2 } from "@internal/clickhouse";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { TaskDetailPresenter } from "~/presenters/v3/TaskDetailPresenter.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
const organizationId = "org_activity_test";
|
||||
const projectId = "project_activity_test";
|
||||
const environmentId = "env_activity_test";
|
||||
const taskSlug = "my-activity-task";
|
||||
|
||||
function makeRun(overrides: Partial<TaskRunV2>): TaskRunV2 {
|
||||
const createdAt = overrides.created_at ?? Date.now();
|
||||
return {
|
||||
environment_id: environmentId,
|
||||
organization_id: organizationId,
|
||||
project_id: projectId,
|
||||
run_id: `run_${randomUUID()}`,
|
||||
friendly_id: `friendly_${randomUUID()}`,
|
||||
updated_at: createdAt,
|
||||
created_at: createdAt,
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
environment_type: "PRODUCTION",
|
||||
attempt: 1,
|
||||
engine: "V2",
|
||||
task_identifier: taskSlug,
|
||||
queue: "my-queue",
|
||||
schedule_id: "",
|
||||
batch_id: "",
|
||||
task_version: "",
|
||||
sdk_version: "",
|
||||
cli_version: "",
|
||||
machine_preset: "",
|
||||
root_run_id: "",
|
||||
parent_run_id: "",
|
||||
span_id: "",
|
||||
trace_id: "",
|
||||
idempotency_key: "",
|
||||
expiration_ttl: "",
|
||||
_version: "1",
|
||||
_is_deleted: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskDetailPresenter.getActivity (ClickHouse-only)", () => {
|
||||
clickhouseTest(
|
||||
"buckets task_runs_v2 activity by status group, excludes deleted, never reads Postgres",
|
||||
async ({ clickhouseContainer }) => {
|
||||
const clickhouse = new ClickHouse({
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
name: "task-detail-activity-test",
|
||||
compression: { request: true },
|
||||
});
|
||||
|
||||
const insert = clickhouse.writer.insert({
|
||||
name: "insertTaskRunsActivityTest",
|
||||
table: "trigger_dev.task_runs_v2",
|
||||
schema: z.any(),
|
||||
settings: { async_insert: 0, enable_json_type: 1, type_json_skip_duplicated_paths: 1 },
|
||||
});
|
||||
|
||||
// 6h window => 300s (5-minute) buckets => 72 buckets (chooseBucketSeconds targets ~72).
|
||||
const from = new Date("2026-01-01T00:00:00Z");
|
||||
const to = new Date("2026-01-01T06:00:00Z");
|
||||
const BUCKET_MS = 5 * 60 * 1000;
|
||||
|
||||
// 00:30 bucket: 1 COMPLETED, 1 FAILED (+ 1 deleted, excluded).
|
||||
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING + unknown-status = 2.
|
||||
const bucket0 = new Date("2026-01-01T00:30:00Z").getTime();
|
||||
const bucket2 = new Date("2026-01-01T02:30:00Z").getTime();
|
||||
|
||||
const rows = [
|
||||
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY" }),
|
||||
makeRun({ created_at: bucket0, status: "CRASHED" }), // FAILED group
|
||||
makeRun({ created_at: bucket2, status: "CANCELED" }), // CANCELED group
|
||||
makeRun({ created_at: bucket2, status: "EXECUTING" }), // RUNNING group
|
||||
makeRun({ created_at: bucket2, status: "SOME_UNKNOWN_STATUS" }), // folds into RUNNING
|
||||
// Deleted row — distinct run, _is_deleted = 1, must NOT be counted.
|
||||
makeRun({ created_at: bucket0, status: "COMPLETED_SUCCESSFULLY", _is_deleted: 1 }),
|
||||
];
|
||||
|
||||
const [insertError] = await insert(rows);
|
||||
expect(insertError).toBeNull();
|
||||
|
||||
const throwingReplica = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("getActivity must not touch the run-ops Prisma client");
|
||||
},
|
||||
}
|
||||
) as never;
|
||||
|
||||
const presenter = new TaskDetailPresenter(throwingReplica, clickhouse);
|
||||
|
||||
const activity = await presenter.getActivity({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
taskSlug,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
// Stable legend, fixed group order.
|
||||
expect(activity.statuses).toEqual(["COMPLETED", "FAILED", "CANCELED", "RUNNING"]);
|
||||
|
||||
// 72 five-minute buckets, every bucket carries all four group keys.
|
||||
expect(activity.data).toHaveLength(72);
|
||||
for (const point of activity.data) {
|
||||
expect(typeof point.bucket).toBe("number");
|
||||
expect(point).toHaveProperty("COMPLETED");
|
||||
expect(point).toHaveProperty("FAILED");
|
||||
expect(point).toHaveProperty("CANCELED");
|
||||
expect(point).toHaveProperty("RUNNING");
|
||||
}
|
||||
|
||||
// Buckets are epoch MILLISECONDS aligned to the 5-minute interval.
|
||||
const byBucket = new Map(activity.data.map((p) => [p.bucket, p]));
|
||||
const p0 = byBucket.get(Math.floor(bucket0 / BUCKET_MS) * BUCKET_MS)!;
|
||||
const p2 = byBucket.get(Math.floor(bucket2 / BUCKET_MS) * BUCKET_MS)!;
|
||||
expect(p0).toBeDefined();
|
||||
expect(p2).toBeDefined();
|
||||
|
||||
// 00:30 bucket: 1 COMPLETED, 1 FAILED, deleted row excluded.
|
||||
expect(p0.COMPLETED).toBe(1);
|
||||
expect(p0.FAILED).toBe(1);
|
||||
expect(p0.CANCELED).toBe(0);
|
||||
expect(p0.RUNNING).toBe(0);
|
||||
|
||||
// 02:30 bucket: 1 CANCELED, RUNNING = EXECUTING (1) + unknown status (1) = 2.
|
||||
expect(p2.COMPLETED).toBe(0);
|
||||
expect(p2.FAILED).toBe(0);
|
||||
expect(p2.CANCELED).toBe(1);
|
||||
expect(p2.RUNNING).toBe(2);
|
||||
|
||||
// Every other bucket is all-zero for every group.
|
||||
for (const point of activity.data) {
|
||||
if (point.bucket === p0.bucket || point.bucket === p2.bucket) continue;
|
||||
expect(point.COMPLETED).toBe(0);
|
||||
expect(point.FAILED).toBe(0);
|
||||
expect(point.CANCELED).toBe(0);
|
||||
expect(point.RUNNING).toBe(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,514 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// The presenter module graph imports `~/v3/runStore.server`, which imports `~/db.server`
|
||||
// at load. Stub it (the sibling runsRepository.readthrough.test.ts does the same) — the
|
||||
// presenter under test is driven entirely through injected real containers, never the
|
||||
// stubbed module singletons.
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
}));
|
||||
|
||||
import { PostgresRunStore } from "@internal/run-store";
|
||||
import { createPostgresContainer, replicationContainerTest } from "@internal/testcontainers";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import superjson from "superjson";
|
||||
import { TestTaskPresenter } from "~/presenters/v3/TestTaskPresenter.server";
|
||||
import { setupClickhouseReplication } from "../utils/replicationUtils";
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
type SeedContext = {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
const JSON_TYPE = "application/json";
|
||||
const SUPERJSON_TYPE = "application/super+json";
|
||||
|
||||
/**
|
||||
* Creates the org/project/(DEVELOPMENT) env parents plus the BackgroundWorker +
|
||||
* BackgroundWorkerTask the presenter resolves the task from. TaskRun FKs require
|
||||
* the org/project/env to exist on every DB a run is hydrated from.
|
||||
*/
|
||||
async function seedParents(
|
||||
prisma: PrismaClient,
|
||||
slug: string,
|
||||
triggerSource: "STANDARD" | "SCHEDULED"
|
||||
): Promise<SeedContext> {
|
||||
const organization = await prisma.organization.create({
|
||||
data: { title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: organization.id,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
const runtimeEnvironment = await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
apiKey: `tr_dev_${slug}`,
|
||||
pkApiKey: `pk_dev_${slug}`,
|
||||
shortcode: `sc-${slug}`,
|
||||
},
|
||||
});
|
||||
|
||||
const worker = await prisma.backgroundWorker.create({
|
||||
data: {
|
||||
friendlyId: `worker_${slug}`,
|
||||
contentHash: `hash-${slug}`,
|
||||
version: "20240101.1",
|
||||
engine: "V2",
|
||||
metadata: {},
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
},
|
||||
});
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: `task_${slug}`,
|
||||
slug: "my-task",
|
||||
filePath: "src/trigger/my-task.ts",
|
||||
exportName: "myTask",
|
||||
workerId: worker.id,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: runtimeEnvironment.id,
|
||||
triggerSource,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: runtimeEnvironment.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the org/project/env parents onto a second DB with the SAME ids. */
|
||||
async function mirrorParents(prisma: PrismaClient, ctx: SeedContext, slug: string): Promise<void> {
|
||||
await prisma.organization.create({
|
||||
data: { id: ctx.organizationId, title: `org-${slug}`, slug: `org-${slug}` },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: ctx.projectId,
|
||||
name: `proj-${slug}`,
|
||||
slug: `proj-${slug}`,
|
||||
organizationId: ctx.organizationId,
|
||||
externalRef: `proj-${slug}`,
|
||||
},
|
||||
});
|
||||
await prisma.runtimeEnvironment.create({
|
||||
data: {
|
||||
id: ctx.environmentId,
|
||||
slug: `env-${slug}`,
|
||||
type: "DEVELOPMENT",
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
apiKey: `tr_dev_${slug}_b`,
|
||||
pkApiKey: `pk_dev_${slug}_b`,
|
||||
shortcode: `sc-${slug}-b`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createRun(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
run: {
|
||||
friendlyId: string;
|
||||
payload: string;
|
||||
payloadType?: string;
|
||||
createdAt?: Date;
|
||||
runTags?: string[];
|
||||
}
|
||||
) {
|
||||
return prisma.taskRun.create({
|
||||
data: {
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: "my-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
payload: run.payload,
|
||||
payloadType: run.payloadType ?? JSON_TYPE,
|
||||
traceId: run.friendlyId,
|
||||
spanId: run.friendlyId,
|
||||
queue: "task/my-task",
|
||||
runTags: run.runTags ?? [],
|
||||
runtimeEnvironmentId: ctx.environmentId,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
environmentType: "DEVELOPMENT",
|
||||
engine: "V2",
|
||||
...(run.createdAt ? { createdAt: run.createdAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Copy a row created on one DB onto another DB with the SAME id. */
|
||||
async function copyRunWithId(
|
||||
prisma: PrismaClient,
|
||||
ctx: SeedContext,
|
||||
source: { id: string; friendlyId: string; payload: string; payloadType: string; createdAt: Date }
|
||||
) {
|
||||
const created = await createRun(prisma, ctx, {
|
||||
friendlyId: source.friendlyId,
|
||||
payload: source.payload,
|
||||
payloadType: source.payloadType,
|
||||
createdAt: source.createdAt,
|
||||
});
|
||||
await prisma.taskRun.update({
|
||||
where: { friendlyId: source.friendlyId },
|
||||
data: { id: source.id },
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
function envFor(ctx: SeedContext) {
|
||||
return {
|
||||
id: ctx.environmentId,
|
||||
type: "DEVELOPMENT" as const,
|
||||
projectId: ctx.projectId,
|
||||
organizationId: ctx.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
/** A legacy-replica handle whose taskRun.findMany throws — proves it is never hydrated from. */
|
||||
function throwingLegacyReplica(prisma: PrismaClient): PrismaClient {
|
||||
return new Proxy(prisma, {
|
||||
get(target, prop) {
|
||||
if (prop === "taskRun") {
|
||||
return new Proxy((target as any).taskRun, {
|
||||
get(trTarget, trProp) {
|
||||
if (trProp === "findMany") {
|
||||
return async () => {
|
||||
throw new Error("legacy replica hydrate must not be invoked");
|
||||
};
|
||||
}
|
||||
return (trTarget as any)[trProp];
|
||||
},
|
||||
});
|
||||
}
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("TestTaskPresenter recent-payloads read-through (PG14 legacy + PG17 new)", () => {
|
||||
// payloadType parity: split union of NEW + legacy-replica, JSON-only, createdAt desc.
|
||||
replicationContainerTest(
|
||||
"split mode hydrates the 10-most-recent CH id-set as the union of NEW + legacy-replica rows, payloadType filtered, createdAt desc",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
// PG14 = legacy read replica AND the replication source feeding the CH id-set.
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "split1", "STANDARD");
|
||||
await mirrorParents(prismaNew, ctx, "split1");
|
||||
|
||||
const base = Date.now();
|
||||
const at = (offsetMs: number) => new Date(base - offsetMs);
|
||||
|
||||
// All rows seeded on PG14 (legacy + replication source -> CH gets the full id-set).
|
||||
const legacyOld = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_legacy_old",
|
||||
payload: JSON.stringify({ kind: "legacy-old" }),
|
||||
createdAt: at(4000),
|
||||
});
|
||||
const nonJson = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_nonjson",
|
||||
payload: "binary-bytes",
|
||||
payloadType: "application/octet-stream",
|
||||
createdAt: at(3000),
|
||||
});
|
||||
const migratedSuper = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_migrated_super",
|
||||
payload: JSON.stringify({ kind: "migrated-super" }),
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: at(2000),
|
||||
});
|
||||
const migratedJson = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_migrated_json",
|
||||
payload: JSON.stringify({ kind: "migrated-json" }),
|
||||
createdAt: at(1000),
|
||||
});
|
||||
|
||||
// The two "migrated" runs ALSO live on NEW (PG17), authoritative during retention.
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migratedSuper.id,
|
||||
friendlyId: migratedSuper.friendlyId,
|
||||
payload: migratedSuper.payload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: migratedSuper.createdAt,
|
||||
});
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migratedJson.id,
|
||||
friendlyId: migratedJson.friendlyId,
|
||||
payload: migratedJson.payload,
|
||||
payloadType: JSON_TYPE,
|
||||
createdAt: migratedJson.createdAt,
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
expect(result.foundTask).toBe(true);
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
|
||||
// Union of the JSON/super+json rows across both DBs, createdAt desc, non-JSON absent.
|
||||
expect(result.runs.map((r) => r.friendlyId)).toEqual([
|
||||
"run_migrated_json",
|
||||
"run_migrated_super",
|
||||
"run_legacy_old",
|
||||
]);
|
||||
expect(result.runs.map((r) => r.friendlyId)).not.toContain("run_nonjson");
|
||||
expect(result.runs.find((r) => r.id === nonJson.id)).toBeUndefined();
|
||||
|
||||
// payloadType round-trips byte-identically across PG14/PG17.
|
||||
expect(result.runs.find((r) => r.id === migratedSuper.id)!.payloadType).toBe(
|
||||
SUPERJSON_TYPE
|
||||
);
|
||||
expect(result.runs.find((r) => r.id === migratedJson.id)!.payloadType).toBe(JSON_TYPE);
|
||||
expect(result.runs.find((r) => r.id === legacyOld.id)!.payloadType).toBe(JSON_TYPE);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Old in-retention run served from the legacy READ REPLICA only (no legacyWriter field exists).
|
||||
replicationContainerTest(
|
||||
"an in-retention legacy-only run hydrates from the legacy replica handle",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "legacyonly", "STANDARD");
|
||||
await mirrorParents(prismaNew, ctx, "legacyonly");
|
||||
|
||||
const legacyOnly = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_legacy_only",
|
||||
payload: JSON.stringify({ kind: "legacy-only" }),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// The deps shape exposes only `legacyReplica` — there is no `legacyWriter`/primary
|
||||
// field, so the legacy primary is structurally unreachable from this hydrate.
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
expect(result.runs.map((r) => r.id)).toEqual([legacyOnly.id]);
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Passthrough (single-DB): one plain store read, the legacy replica never touched.
|
||||
replicationContainerTest(
|
||||
"single-DB passthrough hydrates from one store read and never touches the legacy replica",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const ctx = await seedParents(prisma, "passthrough", "STANDARD");
|
||||
const base = Date.now();
|
||||
const newer = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_newer",
|
||||
payload: JSON.stringify({ n: 2 }),
|
||||
createdAt: new Date(base - 1000),
|
||||
});
|
||||
const older = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_older",
|
||||
payload: JSON.stringify({ n: 1 }),
|
||||
createdAt: new Date(base - 2000),
|
||||
});
|
||||
await createRun(prisma, ctx, {
|
||||
friendlyId: "run_nonjson",
|
||||
payload: "bytes",
|
||||
payloadType: "application/octet-stream",
|
||||
createdAt: new Date(base - 500),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
// No readThrough (split off). Inject a throwing legacy replica to prove the split branch
|
||||
// is never entered: a runStore whose findRuns drives the single `prisma` handle.
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: false,
|
||||
legacyReplica: throwingLegacyReplica(prisma),
|
||||
},
|
||||
new PostgresRunStore({ prisma, readOnlyPrisma: prisma })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "STANDARD") {
|
||||
throw new Error("expected a STANDARD task");
|
||||
}
|
||||
// createdAt desc, JSON-only.
|
||||
expect(result.runs.map((r) => r.id)).toEqual([newer.id, older.id]);
|
||||
}
|
||||
);
|
||||
|
||||
// SCHEDULED-source parity: same hydrate path, ScheduledRun mapping exercised.
|
||||
replicationContainerTest(
|
||||
"SCHEDULED task: split union parses to ScheduledRun shape identically to single-DB",
|
||||
async ({ clickhouseContainer, redisOptions, postgresContainer, prisma, network }) => {
|
||||
const { clickhouse } = await setupClickhouseReplication({
|
||||
prisma,
|
||||
databaseUrl: postgresContainer.getConnectionUri(),
|
||||
clickhouseUrl: clickhouseContainer.getConnectionUrl(),
|
||||
redisOptions,
|
||||
});
|
||||
|
||||
const { url: newUrl } = await createPostgresContainer(network, {
|
||||
imageTag: "docker.io/postgres:17",
|
||||
});
|
||||
const prismaNew = new PrismaClient({ datasources: { db: { url: newUrl } } });
|
||||
|
||||
try {
|
||||
const ctx = await seedParents(prisma, "scheduled", "SCHEDULED");
|
||||
await mirrorParents(prismaNew, ctx, "scheduled");
|
||||
|
||||
// super+json so parsePacket revives the Date fields the ScheduledTaskPayload schema requires.
|
||||
const schedulePayload = superjson.stringify({
|
||||
scheduleId: "sched_1",
|
||||
type: "IMPERATIVE",
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
timezone: "UTC",
|
||||
externalId: "ext-1",
|
||||
upcoming: [new Date("2026-01-02T00:00:00.000Z")],
|
||||
});
|
||||
|
||||
const base = Date.now();
|
||||
const migrated = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_sched_new",
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: new Date(base - 1000),
|
||||
});
|
||||
await copyRunWithId(prismaNew, ctx, {
|
||||
id: migrated.id,
|
||||
friendlyId: migrated.friendlyId,
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: migrated.createdAt,
|
||||
});
|
||||
const legacy = await createRun(prisma, ctx, {
|
||||
friendlyId: "run_sched_legacy",
|
||||
payload: schedulePayload,
|
||||
payloadType: SUPERJSON_TYPE,
|
||||
createdAt: new Date(base - 2000),
|
||||
});
|
||||
|
||||
await setTimeout(1500);
|
||||
|
||||
const presenter = new TestTaskPresenter(
|
||||
prisma,
|
||||
clickhouse,
|
||||
{
|
||||
splitEnabled: true,
|
||||
newClient: prismaNew,
|
||||
legacyReplica: prisma,
|
||||
},
|
||||
new PostgresRunStore({ prisma: prismaNew, readOnlyPrisma: prismaNew })
|
||||
);
|
||||
|
||||
const result = await presenter.call({
|
||||
userId: "user_1",
|
||||
projectId: ctx.projectId,
|
||||
environment: envFor(ctx),
|
||||
taskIdentifier: "my-task",
|
||||
});
|
||||
|
||||
if (!result.foundTask || result.triggerSource !== "SCHEDULED") {
|
||||
throw new Error("expected a SCHEDULED task");
|
||||
}
|
||||
expect(result.runs.map((r) => r.id)).toEqual([migrated.id, legacy.id]);
|
||||
// Parsed ScheduledRun payload shape.
|
||||
expect(result.runs[0].payload.timezone).toBe("UTC");
|
||||
expect(result.runs[0].payload.externalId).toBe("ext-1");
|
||||
} finally {
|
||||
await prismaNew.$disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapRunToLiveFields } from "~/presenters/v3/mapRunToLiveFields.server";
|
||||
|
||||
describe("mapRunToLiveFields", () => {
|
||||
it("maps an executing run with lockedAt fallback and non-final flags", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
const lockedAt = new Date("2026-05-07T09:59:50.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_123",
|
||||
status: "EXECUTING",
|
||||
updatedAt,
|
||||
startedAt: null,
|
||||
lockedAt,
|
||||
completedAt: null,
|
||||
usageDurationMs: BigInt(2500),
|
||||
costInCents: 10,
|
||||
baseCostInCents: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
friendlyId: "run_123",
|
||||
status: "EXECUTING",
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
startedAt: lockedAt.toISOString(),
|
||||
finishedAt: undefined,
|
||||
hasFinished: false,
|
||||
isCancellable: true,
|
||||
isPending: false,
|
||||
usageDurationMs: 2500,
|
||||
costInCents: 10,
|
||||
baseCostInCents: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a final run and prefers completedAt for finishedAt", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
const startedAt = new Date("2026-05-07T09:59:00.000Z");
|
||||
const completedAt = new Date("2026-05-07T09:59:30.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_456",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
updatedAt,
|
||||
startedAt,
|
||||
lockedAt: null,
|
||||
completedAt,
|
||||
usageDurationMs: 1200,
|
||||
costInCents: 20,
|
||||
baseCostInCents: 7,
|
||||
});
|
||||
|
||||
expect(result.finishedAt).toBe(completedAt.toISOString());
|
||||
expect(result.startedAt).toBe(startedAt.toISOString());
|
||||
expect(result.hasFinished).toBe(true);
|
||||
expect(result.isCancellable).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to updatedAt when a final run has no completedAt", () => {
|
||||
const updatedAt = new Date("2026-05-07T10:00:00.000Z");
|
||||
|
||||
const result = mapRunToLiveFields({
|
||||
friendlyId: "run_789",
|
||||
status: "CRASHED",
|
||||
updatedAt,
|
||||
startedAt: null,
|
||||
lockedAt: null,
|
||||
completedAt: null,
|
||||
usageDurationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
});
|
||||
|
||||
expect(result.finishedAt).toBe(updatedAt.toISOString());
|
||||
expect(result.hasFinished).toBe(true);
|
||||
expect(result.isPending).toBe(false);
|
||||
expect(result.isCancellable).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user