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.
163 lines
4.8 KiB
TypeScript
163 lines
4.8 KiB
TypeScript
import {
|
|
type TaskTriggerSource,
|
|
type PrismaClient,
|
|
type PrismaClientOrTransaction,
|
|
boundedIn,
|
|
} from "@trigger.dev/database";
|
|
import { $replica, prisma } from "~/db.server";
|
|
import { getAllTaskIdentifiers } from "~/models/task.server";
|
|
import { logger } from "./logger.server";
|
|
import {
|
|
getTaskIdentifiersFromCache,
|
|
populateTaskIdentifierCache,
|
|
type TaskIdentifierEntry,
|
|
} from "./taskIdentifierCache.server";
|
|
|
|
function toTriggerSource(source: string | undefined): TaskTriggerSource {
|
|
const normalized = source?.toUpperCase();
|
|
if (normalized === "AGENT") return "AGENT";
|
|
if (normalized === "SCHEDULED" || normalized === "SCHEDULE") return "SCHEDULED";
|
|
return "STANDARD";
|
|
}
|
|
|
|
export async function syncTaskIdentifiers(
|
|
environmentId: string,
|
|
projectId: string,
|
|
workerId: string,
|
|
tasks: { id: string; triggerSource?: string }[],
|
|
db: PrismaClient = prisma
|
|
): Promise<void> {
|
|
const slugs = tasks.map((t) => t.id);
|
|
const now = new Date();
|
|
|
|
// Group slugs by resolved triggerSource for bulk updates
|
|
const slugsBySource = new Map<TaskTriggerSource, string[]>();
|
|
for (const task of tasks) {
|
|
const source = toTriggerSource(task.triggerSource);
|
|
const existing = slugsBySource.get(source);
|
|
if (existing) {
|
|
existing.push(task.id);
|
|
} else {
|
|
slugsBySource.set(source, [task.id]);
|
|
}
|
|
}
|
|
|
|
// Batch: insert new rows, update existing rows per source group, archive removed tasks
|
|
await db.$transaction([
|
|
// Insert any new task identifiers (skips rows that already exist)
|
|
db.taskIdentifier.createMany({
|
|
data: tasks.map((task) => ({
|
|
runtimeEnvironmentId: environmentId,
|
|
projectId,
|
|
slug: task.id,
|
|
currentTriggerSource: toTriggerSource(task.triggerSource),
|
|
currentWorkerId: workerId,
|
|
})),
|
|
skipDuplicates: true,
|
|
}),
|
|
// Update existing rows — one updateMany per distinct triggerSource value
|
|
...Array.from(slugsBySource.entries()).map(([source, taskSlugs]) =>
|
|
db.taskIdentifier.updateMany({
|
|
where: {
|
|
runtimeEnvironmentId: environmentId,
|
|
slug: { in: boundedIn(taskSlugs) },
|
|
},
|
|
data: {
|
|
currentTriggerSource: source,
|
|
currentWorkerId: workerId,
|
|
lastSeenAt: now,
|
|
isInLatestDeployment: true,
|
|
},
|
|
})
|
|
),
|
|
// Archive tasks no longer in this deploy
|
|
db.taskIdentifier.updateMany({
|
|
where: {
|
|
runtimeEnvironmentId: environmentId,
|
|
slug: { notIn: boundedIn(slugs) },
|
|
isInLatestDeployment: true,
|
|
},
|
|
data: { isInLatestDeployment: false },
|
|
}),
|
|
]);
|
|
|
|
const allIdentifiers = await db.taskIdentifier.findMany({
|
|
where: { runtimeEnvironmentId: environmentId },
|
|
select: {
|
|
slug: true,
|
|
currentTriggerSource: true,
|
|
isInLatestDeployment: true,
|
|
},
|
|
});
|
|
|
|
populateTaskIdentifierCache(
|
|
environmentId,
|
|
allIdentifiers.map((t) => ({
|
|
slug: t.slug,
|
|
triggerSource: t.currentTriggerSource,
|
|
isInLatestDeployment: t.isInLatestDeployment,
|
|
}))
|
|
).catch((error) => {
|
|
logger.error("Failed to populate task identifier cache after sync", { environmentId, error });
|
|
});
|
|
}
|
|
|
|
function sortEntries(entries: TaskIdentifierEntry[]): TaskIdentifierEntry[] {
|
|
return entries.sort((a, b) => {
|
|
if (a.isInLatestDeployment !== b.isInLatestDeployment) return a.isInLatestDeployment ? -1 : 1;
|
|
return a.slug.localeCompare(b.slug);
|
|
});
|
|
}
|
|
|
|
export async function getTaskIdentifiers(
|
|
environmentId: string,
|
|
db: PrismaClientOrTransaction = $replica
|
|
): Promise<TaskIdentifierEntry[]> {
|
|
const cached = await getTaskIdentifiersFromCache(environmentId);
|
|
if (cached) return sortEntries(cached);
|
|
|
|
const dbRows = await db.taskIdentifier.findMany({
|
|
where: { runtimeEnvironmentId: environmentId },
|
|
select: {
|
|
slug: true,
|
|
currentTriggerSource: true,
|
|
isInLatestDeployment: true,
|
|
},
|
|
});
|
|
|
|
if (dbRows.length > 0) {
|
|
const entries: TaskIdentifierEntry[] = dbRows.map((t) => ({
|
|
slug: t.slug,
|
|
triggerSource: t.currentTriggerSource,
|
|
isInLatestDeployment: t.isInLatestDeployment,
|
|
}));
|
|
|
|
populateTaskIdentifierCache(environmentId, entries).catch((error) => {
|
|
logger.error("Failed to populate task identifier cache after DB read", {
|
|
environmentId,
|
|
error,
|
|
});
|
|
});
|
|
|
|
return sortEntries(entries);
|
|
}
|
|
|
|
const legacyRows = await getAllTaskIdentifiers(db, environmentId);
|
|
const entries: TaskIdentifierEntry[] = legacyRows.map((t) => ({
|
|
slug: t.slug,
|
|
triggerSource: t.triggerSource,
|
|
isInLatestDeployment: true,
|
|
}));
|
|
|
|
if (entries.length > 0) {
|
|
populateTaskIdentifierCache(environmentId, entries).catch((error) => {
|
|
logger.error("Failed to populate task identifier cache after legacy fallback", {
|
|
environmentId,
|
|
error,
|
|
});
|
|
});
|
|
}
|
|
|
|
return sortEntries(entries);
|
|
}
|