Files
triggerdotdev--trigger.dev/apps/webapp/app/services/realtime/runReader.server.ts
T
2026-07-13 13:32:57 +08:00

175 lines
5.8 KiB
TypeScript

import {
type Prisma,
type PrismaClient,
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import type { RunStore } from "@internal/run-store";
import { BoundedTtlCache } from "./boundedTtlCache";
import { RESERVED_COLUMNS, type RealtimeRunRow } from "./electricStreamProtocol.server";
/**
* RunReader — the pluggable read half of the native-backend realtime feed: ClickHouse is filter-only
* (resolves ids), Postgres always hydrates row columns. Owns the `RunHydrator` (by-id) and the
* `RunListResolver` interface (the tag/list filter -> id-set seam, implemented over ClickHouse).
*/
/** The TaskRun columns the realtime feed projects (mirrors DEFAULT_ELECTRIC_COLUMNS). */
export const RUN_HYDRATOR_SELECT = {
id: true,
taskIdentifier: true,
createdAt: true,
updatedAt: true,
startedAt: true,
delayUntil: true,
queuedAt: true,
expiredAt: true,
completedAt: true,
friendlyId: true,
number: true,
isTest: true,
status: true,
usageDurationMs: true,
costInCents: true,
baseCostInCents: true,
ttl: true,
payload: true,
payloadType: true,
metadata: true,
metadataType: true,
output: true,
outputType: true,
runTags: true,
error: true,
realtimeStreams: true,
} satisfies Prisma.TaskRunSelect;
/** Columns hydrated regardless of `skipColumns`: `id` keys the row, `updatedAt` drives the offset and working-set diff. */
const ALWAYS_HYDRATED_COLUMNS = new Set<string>(["id", "updatedAt", ...RESERVED_COLUMNS]);
/** Project `RUN_HYDRATOR_SELECT` down to the columns the client didn't skip (plus
* the always-needed ones). An empty skip set returns the full select unchanged. */
export function buildHydratorSelect(skipColumns: string[] = []): Prisma.TaskRunSelect {
if (skipColumns.length === 0) {
return RUN_HYDRATOR_SELECT;
}
const skip = new Set(skipColumns);
const select: Record<string, boolean> = {};
for (const column of Object.keys(RUN_HYDRATOR_SELECT)) {
if (ALWAYS_HYDRATED_COLUMNS.has(column) || !skip.has(column)) {
select[column] = true;
}
}
return select as Prisma.TaskRunSelect;
}
export type RunListFilter = {
organizationId: string;
projectId: string;
environmentId: string;
/** Contains-ANY tag match (OR). Omit/empty for non-tag feeds. */
tags?: string[];
/** Restrict to a single batch (internal batch id) — the batch feed. */
batchId?: string;
/** Lower bound on createdAt (the tag-list feed pins this; batch omits it). */
createdAtAfter?: Date;
/** Hard cap on the result set so a broad filter can't unbound the snapshot. */
limit: number;
};
/** Resolves a tag/list filter into the matching run id-set, filter-only (rows hydrated from Postgres by id afterward). ClickHouse impl in `clickHouseRunListResolver.server.ts`. */
export interface RunListResolver {
resolveMatchingRunIds(filter: RunListFilter): Promise<string[]>;
}
export type RunHydratorOptions = {
/** A read-replica Prisma client (`$replica`). Always Postgres. */
replica: Pick<PrismaClient, "taskRun">;
/** RunStore the reads are routed through; `replica` is passed as the read client. */
runStore: RunStore;
/** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */
cacheTtlMs?: number;
/** Hard cap on cache entries before expired entries are swept. */
maxCacheEntries?: number;
};
const DEFAULT_CACHE_TTL_MS = 250;
const DEFAULT_MAX_CACHE_ENTRIES = 5_000;
/** Hydrates runs by id through the runStore seam (split routing lives in the store, below this file), projected to the realtime columns; concurrent same-run refetches are single-flighted + short-TTL cached. */
export class RunHydrator {
readonly #inflight = new Map<string, Promise<RealtimeRunRow | null>>();
readonly #cache: BoundedTtlCache<RealtimeRunRow | null>;
readonly #cacheTtlMs: number;
constructor(private readonly options: RunHydratorOptions) {
this.#cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
this.#cache = new BoundedTtlCache(
this.#cacheTtlMs,
options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
);
}
async getRunById(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const key = `${environmentId}:${runId}`;
if (this.#cacheTtlMs > 0) {
// A cached null is a valid "run not found" hit; only undefined is a miss.
const cached = this.#cache.get(key);
if (cached !== undefined) {
return cached;
}
}
const existing = this.#inflight.get(key);
if (existing) {
return existing;
}
const promise = this.#fetch(environmentId, runId).finally(() => this.#inflight.delete(key));
this.#inflight.set(key, promise);
const row = await promise;
if (this.#cacheTtlMs > 0) {
this.#cache.set(key, row);
}
return row;
}
/** Hydrate many runs by id in one query (order not guaranteed); `skipColumns` projects the SELECT so dropped columns aren't shipped. */
async hydrateByIds(
environmentId: string,
ids: string[],
skipColumns: string[] = []
): Promise<RealtimeRunRow[]> {
if (ids.length === 0) {
return [];
}
const rows = await this.options.runStore.findRuns(
{
where: {
runtimeEnvironmentId: environmentId,
id: { in: ids },
},
select: buildHydratorSelect(skipColumns),
},
this.options.replica as PrismaClientOrTransaction
);
return rows as unknown as RealtimeRunRow[];
}
async #fetch(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const run = await this.options.runStore.findRun(
{
id: runId,
runtimeEnvironmentId: environmentId,
},
{ select: RUN_HYDRATOR_SELECT },
this.options.replica as PrismaClientOrTransaction
);
return (run ?? null) as RealtimeRunRow | null;
}
}