Files
2026-07-13 13:32:57 +08:00

367 lines
11 KiB
TypeScript

import { type ClickHouse } from "@internal/clickhouse";
import type { MachinePresetName } from "@trigger.dev/core/v3";
import { RunAnnotations } from "@trigger.dev/core/v3/schemas";
import {
type PrismaClient,
type PrismaClientOrTransaction,
type TaskRunStatus,
} from "@trigger.dev/database";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { env } from "~/env.server";
import {
createCache,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
} from "@internal/cache";
import { RedisCacheStore } from "~/services/unkey/redisCacheStore.server";
import { singleton } from "~/utils/singleton";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
// Positive-only cache: only envs known to have runs are stored (empty envs are re-checked),
// so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis.
const runsExistCache = singleton("runsExistCache", () => {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(5000, "runs-has-runs-cache");
const redis = new RedisCacheStore({
name: "runs-has-runs",
connection: {
keyPrefix: "tr:cache:runs-has-runs",
port: env.CACHE_REDIS_PORT,
host: env.CACHE_REDIS_HOST,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
return createCache({
hasRuns: new Namespace<boolean>(ctx, {
stores: [memory, redis],
fresh: env.RUN_LIST_HAS_RUNS_CACHE_FRESH_MS,
stale: env.RUN_LIST_HAS_RUNS_CACHE_STALE_MS,
}),
});
});
export type RunListOptions = {
userId?: string;
projectId: string;
//filters
tasks?: string[];
versions?: string[];
statuses?: TaskRunStatus[];
tags?: string[];
scheduleId?: string;
period?: string;
bulkId?: string;
from?: number;
to?: number;
isTest?: boolean;
rootOnly?: boolean;
batchId?: string;
runId?: string[];
queues?: string[];
regions?: string[];
machines?: MachinePresetName[];
errorId?: string;
sources?: string[];
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
// Run the empty-state "has any run ever" probe. Only the runs list consumes it.
includeHasAnyRuns?: boolean;
};
const DEFAULT_PAGE_SIZE = 25;
export type NextRunList = Awaited<ReturnType<NextRunListPresenter["call"]>>;
export type NextRunListItem = NextRunList["runs"][0];
export type NextRunListAppliedFilters = NextRunList["filters"];
export class NextRunListPresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse,
private readonly readThroughDeps?: {
// The new run-ops client + the legacy run-ops read replica (never the legacy writer).
// Omitted => single-DB / self-host: both default to `replica` (passthrough).
newClient?: PrismaClientOrTransaction;
legacyReplica?: PrismaClientOrTransaction;
// Resolved boot constant from isSplitEnabled(). When false/absent:
// list hydrate runs passthrough and the empty-state probe is one plain findFirst.
splitEnabled?: boolean;
}
) {}
// Empty-state existence probe, served from ClickHouse (same connection as the runs
// list) so it no longer scans TaskRun in Postgres. SWR-cached to spare ClickHouse;
// RUN_LIST_HAS_RUNS_LOOKBACK_DAYS bounds the prove-absence partition scan.
async #anyRunExistsInEnv(
runsRepository: RunsRepository,
organizationId: string,
projectId: string,
environmentId: string
): Promise<boolean> {
const lookbackDays = env.RUN_LIST_HAS_RUNS_LOOKBACK_DAYS;
const createdAtLowerBoundMs =
lookbackDays > 0 ? Date.now() - lookbackDays * 24 * 60 * 60 * 1000 : undefined;
const result = await runsExistCache.hasRuns.swr(environmentId, async () => {
const exists = await runsRepository.runExistsInEnvironment({
organizationId,
projectId,
environmentId,
createdAtLowerBoundMs,
});
// undefined (not false) so swr does NOT cache the empty result — re-check until a run exists.
return exists ? true : undefined;
});
return result.val ?? false;
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
bulkId,
isTest,
rootOnly,
batchId,
runId,
queues,
regions,
machines,
errorId,
sources,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
includeHasAnyRuns = false,
}: RunListOptions
) {
//get the time values from the raw values (including a default period)
const time = timeFilters({
period,
from,
to,
});
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
(sources !== undefined && sources.length > 0) ||
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
(bulkId !== undefined && bulkId !== "") ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
batchId !== undefined ||
(runId !== undefined && runId.length > 0) ||
(queues !== undefined && queues.length > 0) ||
(regions !== undefined && regions.length > 0) ||
(machines !== undefined && machines.length > 0) ||
(errorId !== undefined && errorId !== "") ||
typeof isTest === "boolean" ||
rootOnly === true ||
!time.isDefault;
const possibleTasksAsync = getTaskIdentifiers(environmentId);
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
projectId: projectId,
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);
// If the bulk action isn't in the most recent ones, add it separately
if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});
if (selectedBulkAction) {
bulkActions.push(selectedBulkAction);
}
}
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
readThrough: this.readThroughDeps
? {
newClient: this.readThroughDeps.newClient ?? this.replica,
legacyReplica: this.readThroughDeps.legacyReplica ?? this.replica,
splitEnabled: this.readThroughDeps.splitEnabled ?? false,
}
: undefined,
});
function clampToNow(date: Date): Date {
const now = new Date();
return date > now ? now : date;
}
const { runs, pagination } = await runsRepository.listRuns({
organizationId,
environmentId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
from: time.from ? time.from.getTime() : undefined,
to: time.to ? clampToNow(time.to).getTime() : undefined,
isTest,
rootOnly,
batchId,
runId,
bulkId,
queues,
regions,
machines,
errorId,
taskKinds: sources,
page: {
size: pageSize,
cursor,
direction,
},
});
let hasAnyRuns = runs.length > 0;
if (!hasAnyRuns && includeHasAnyRuns) {
hasAnyRuns = await this.#anyRunExistsInEnv(
runsRepository,
organizationId,
projectId,
environmentId
);
}
return {
runs: runs.map((run) => {
const hasFinished = isFinalRunStatus(run.status);
const startedAt = run.startedAt ?? run.lockedAt;
return {
id: run.id,
number: 1,
friendlyId: run.friendlyId,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
startedAt: startedAt ? startedAt.toISOString() : undefined,
delayUntil: run.delayUntil ? run.delayUntil.toISOString() : undefined,
hasFinished,
finishedAt: hasFinished
? (run.completedAt?.toISOString() ?? run.updatedAt.toISOString())
: undefined,
isTest: run.isTest,
status: run.status,
version: run.taskVersion,
taskIdentifier: run.taskIdentifier,
spanId: run.spanId,
isReplayable: true,
isCancellable: isCancellableRunStatus(run.status),
isPending: isPendingRunStatus(run.status),
environment: displayableEnvironment,
idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined,
ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs),
tags: run.runTags ? run.runTags.sort((a, b) => a.localeCompare(b)) : [],
depth: run.depth,
rootTaskRunId: run.rootTaskRunId,
metadata: run.metadata,
metadataType: run.metadataType,
machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined,
queue: {
name: run.queue.replace("task/", ""),
type: run.queue.startsWith("task/") ? "task" : "custom",
},
region: regionForDisplay(run.region, run.workerQueue),
taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD",
};
}),
pagination: {
next: pagination.nextCursor ?? undefined,
previous: pagination.previousCursor ?? undefined,
},
possibleTasks,
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
from: time.from,
to: time.to,
},
hasFilters,
hasAnyRuns,
};
}
}