c526528d8f
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
## Summary Prisma expands `in` / `notIn` into one bind parameter per element, so every distinct list length is a separate prepared statement. Where the length tracks data volume (a batch size, a run-graph fan-out, a prior query's id set) one call site can mint hundreds of them. Each is used about once, but inserting it evicts an entry that was being reused, so the cost lands on unrelated queries sharing the pooler's statement cache. An unbounded list also risks the 65535 bind-parameter ceiling. `boundedIn()` pads a filter list to the next power of two by repeating its last element. `IN` and `NOT IN` ignore duplicates, so results are unchanged, and a call site drops from one statement per length to at most `log2(cap)`. Applied to all existing sites. ## Enforcement Two oxlint rules require the helper: a list filter must be an inline array literal or a `boundedIn()` call. - The first covers filters reached through `where` / `having` / `cursor`, and deliberately never descends into `data`, `create`, `update`, `set` or `equals`. A key named `in` in those positions is user data, not a predicate, and rewriting it would corrupt what gets stored or compared. - The second covers bare filter objects passed to where-building helpers, which the first cannot see. It found five sites in the run-graph batch loaders that were otherwise invisible. Both rules follow filters through the shapes they are actually written in: conditional expressions, logical-and objects, spread-conditional properties, computed keys, and call arguments. An array literal only counts as fixed-arity when nothing spreads into it, since `[...new Set(ids)]` has a runtime length. Twelve sites were hidden behind those shapes until the rules handled them. Scoped to `in` and `notIn`. The scalar-list filters `hasSome` and `hasEvery` compile to `&& $1` and `@> $1`, passing the whole array as a single bind parameter, so their arity never reaches the statement text and there is nothing to bound. Both rules are `error`, so new call sites fail CI. That ratchet has already caught four sites added by other PRs while this one was in review. ## Notes `boundedIn` pads by repeating rather than with null: `x NOT IN (a, b, NULL)` is never true, so null-padding a `notIn` filter would silently return no rows. Lists above 32768 are returned unchanged so padding can never push a query past the parameter limit. Route modules reach the helper through `~/db.server` rather than importing the database barrel directly, since a value import of that barrel into a module that also exports a React component is only safe while dead-code elimination prunes it. Measured on a local rig: 300 distinct list lengths produce 300 prepared statements unpadded, 10 padded. Verified end-to-end against a local stack with the full task-suite sweep, which surfaced no regressions.
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
import {
|
|
type Prisma,
|
|
type PrismaClient,
|
|
type PrismaClientOrTransaction,
|
|
boundedIn,
|
|
} 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 = {
|
|
/** The Prisma client handed to the RunStore as the read client. Always Postgres. A branded
|
|
* replica (`$replica`) keeps routed reads on each store's replica; an unbranded writer
|
|
* (`prisma`) escalates them to each store's own primary. */
|
|
readClient: Pick<PrismaClient, "taskRun">;
|
|
/** RunStore the reads are routed through. */
|
|
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: boundedIn(ids) },
|
|
},
|
|
select: buildHydratorSelect(skipColumns),
|
|
},
|
|
this.options.readClient 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.readClient as PrismaClientOrTransaction
|
|
);
|
|
|
|
return (run ?? null) as RealtimeRunRow | null;
|
|
}
|
|
}
|