Skip to content

Commit 411fff9

Browse files
committed
feat(webapp,database): bound the arity of Prisma list filters
Prisma expands a list filter into one bind parameter per element, so every distinct list length is a separate prepared statement. Where the length tracks data volume, a single call site can mint hundreds of them. Those entries are used once each, but inserting them evicts entries that were 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. Adds boundedIn(), which 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). It pads by repeating rather than with null because x NOT IN (a, b, NULL) is never true. Lists above 32768 are returned unchanged so padding can never push a query past the parameter limit. Two oxlint rules require it: 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, where a key named "in" is user data rather than a predicate. The second covers bare filter objects passed to where-building helpers, which the first cannot see. Applies the helper to all 74 existing call sites.
1 parent c0c21c2 commit 411fff9

44 files changed

Lines changed: 508 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.oxlintrc.json

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
{
22
"$schema": "./node_modules/oxlint/configuration_schema.json",
3-
"plugins": ["typescript", "import", "react"],
3+
"plugins": [
4+
"typescript",
5+
"import",
6+
"react"
7+
],
48
"jsPlugins": [
59
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
6-
"./oxlint-plugins/runops-residency.mjs"
10+
"./oxlint-plugins/runops-residency.mjs",
11+
"./oxlint-plugins/prisma-in-filter.mjs"
712
],
813
"ignorePatterns": [
914
"**/dist/**",
@@ -30,28 +35,55 @@
3035
"no-empty-pattern": "off",
3136
"no-control-regex": "off",
3237
"typescript/no-non-null-asserted-optional-chain": "off",
33-
"no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true }],
38+
"no-unused-expressions": [
39+
"warn",
40+
{
41+
"allowShortCircuit": true,
42+
"allowTernary": true
43+
}
44+
],
3445
"typescript/consistent-type-imports": "error",
3546
"import/no-duplicates": "error",
3647
"import/namespace": "off",
3748
"react-hooks/exhaustive-deps": "off",
3849
"react-hooks/rules-of-hooks": "off",
39-
"trigger/no-thrown-unawaited-redirect": "error"
50+
"trigger/no-thrown-unawaited-redirect": "error",
51+
"trigger-prisma/no-unbounded-list-filter": "error",
52+
"trigger-prisma/no-unbounded-list-filter-in-args-helper": "error"
4053
},
4154
"overrides": [
4255
{
43-
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
56+
"files": [
57+
"apps/webapp/app/**/*.ts",
58+
"apps/webapp/app/**/*.tsx"
59+
],
4460
"rules": {
4561
"trigger-runops/no-control-plane-run-graph-access": "error",
4662
"trigger-runops/no-control-plane-in-runops-slot": "error"
4763
}
4864
},
4965
{
50-
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
66+
"files": [
67+
"apps/webapp/app/**/*.test.ts",
68+
"apps/webapp/app/**/*.test.tsx"
69+
],
5170
"rules": {
5271
"trigger-runops/no-control-plane-run-graph-access": "off",
5372
"trigger-runops/no-control-plane-in-runops-slot": "off"
5473
}
74+
},
75+
{
76+
"files": [
77+
"**/*.test.ts",
78+
"**/*.test.tsx",
79+
"**/test/**",
80+
"**/tests/**",
81+
"**/e2e/**"
82+
],
83+
"rules": {
84+
"trigger-prisma/no-unbounded-list-filter": "off",
85+
"trigger-prisma/no-unbounded-list-filter-in-args-helper": "off"
86+
}
5587
}
5688
]
5789
}

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "~/v3/vercel/vercelProjectIntegrationSchema";
2525
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
2626
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
27+
import { boundedIn } from "@trigger.dev/database";
2728
import {
2829
callVercelWithRecovery,
2930
wrapVercelCallWithRecovery,
@@ -1415,7 +1416,7 @@ export class VercelIntegrationRepository {
14151416
variable: {
14161417
projectId: params.projectId,
14171418
key: {
1418-
in: varsToSync.map((v) => v.key),
1419+
in: boundedIn(varsToSync.map((v) => v.key)),
14191420
},
14201421
},
14211422
},

apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
1212
import { runStore as defaultRunStore } from "~/v3/runStore.server";
1313
import { BasePresenter } from "./basePresenter.server";
1414

15+
import { boundedIn } from "@trigger.dev/database";
1516
/**
1617
* Run-ops read-through wiring. All optional; absent (or `splitEnabled` falsy) collapses `call` to
1718
* passthrough. `legacyReplica` is a READ REPLICA handle only — there is NO legacy-primary field.
@@ -114,7 +115,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
114115

115116
const taskRuns = await this.runStore.findRuns(
116117
{
117-
where: { id: { in: taskRunIds } },
118+
where: { id: { in: boundedIn(taskRunIds) } },
118119
select: memberRunSelect,
119120
},
120121
this._prisma
@@ -181,7 +182,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
181182
const taskRunIds = batchRun.items.map((item) => item.taskRunId);
182183

183184
const newRows = (await newClient.taskRun.findMany({
184-
where: { id: { in: taskRunIds } },
185+
where: { id: { in: boundedIn(taskRunIds) } },
185186
select: memberRunSelect,
186187
})) as TaskRunWithAttempts[];
187188
const runsById = new Map(newRows.map((run) => [run.id, run]));
@@ -193,7 +194,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
193194
);
194195
if (legacyCandidateIds.length > 0) {
195196
const legacyRows = (await legacyReplica.taskRun.findMany({
196-
where: { id: { in: legacyCandidateIds } },
197+
where: { id: { in: boundedIn(legacyCandidateIds) } },
197198
select: memberRunSelect,
198199
})) as TaskRunWithAttempts[];
199200
for (const run of legacyRows) {

apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { MachinePresetName, parsePacket, RunStatus } from "@trigger.dev/core/v3";
2-
import { type Project, type RuntimeEnvironment, type TaskRunStatus } from "@trigger.dev/database";
2+
import {
3+
type Project,
4+
type RuntimeEnvironment,
5+
type TaskRunStatus,
6+
boundedIn,
7+
} from "@trigger.dev/database";
38
import assertNever from "assert-never";
49
import { z } from "zod";
510
import type { API_VERSIONS } from "~/api/versions";
@@ -208,7 +213,7 @@ export class ApiRunListPresenter extends BasePresenter {
208213
where: {
209214
projectId: project.id,
210215
slug: {
211-
in: searchParams["filter[env]"],
216+
in: boundedIn(searchParams["filter[env]"]),
212217
},
213218
},
214219
});

apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { SyncEnvVarsMapping, EnvSlug } from "~/v3/vercel/vercelProjectInteg
88
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
99
import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server";
1010

11+
import { boundedIn } from "@trigger.dev/database";
1112
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
1213
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
1314

@@ -72,7 +73,7 @@ export class EnvironmentVariablesPresenter {
7273
},
7374
where: {
7475
environmentId: {
75-
in: environmentIds,
76+
in: boundedIn(environmentIds),
7677
},
7778
},
7879
},
@@ -103,7 +104,7 @@ export class EnvironmentVariablesPresenter {
103104
? await this.#replicaClient.user.findMany({
104105
where: {
105106
id: {
106-
in: Array.from(userIds),
107+
in: boundedIn(Array.from(userIds)),
107108
},
108109
},
109110
select: {

apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ const errorsListGranularity = new TimeGranularity([
99
{ max: "3 months", granularity: "1w" },
1010
{ max: "Infinity", granularity: "30d" },
1111
]);
12-
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
12+
import {
13+
type ErrorGroupStatus,
14+
type PrismaClientOrTransaction,
15+
boundedIn,
16+
} from "@trigger.dev/database";
1317
import { type Direction } from "~/components/ListPagination";
1418
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
1519
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -457,7 +461,7 @@ export class ErrorsListPresenter extends BasePresenter {
457461

458462
if (statuses.includes("UNRESOLVED")) {
459463
const excluded = await this.replica.errorGroupState.findMany({
460-
where: { environmentId, status: { in: excludedStatuses } },
464+
where: { environmentId, status: { in: boundedIn(excludedStatuses) } },
461465
select: { taskIdentifier: true, errorFingerprint: true },
462466
});
463467
if (excluded.length === 0) {
@@ -470,7 +474,7 @@ export class ErrorsListPresenter extends BasePresenter {
470474
}
471475

472476
const included = await this.replica.errorGroupState.findMany({
473-
where: { environmentId, status: { in: statuses } },
477+
where: { environmentId, status: { in: boundedIn(statuses) } },
474478
select: { taskIdentifier: true, errorFingerprint: true },
475479
});
476480
if (included.length === 0) {

apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.s
88
import { runStore } from "~/v3/runStore.server";
99
import { isFinalRunStatus } from "~/v3/taskStatus";
1010

11+
import { boundedIn } from "@trigger.dev/database";
1112
export type PlaygroundAgent = {
1213
slug: string;
1314
filePath: string;
@@ -135,7 +136,7 @@ export class PlaygroundPresenter {
135136
const runsById = new Map<string, { friendlyId: string; status: TaskRunStatus }>();
136137
if (runIds.length > 0) {
137138
const runs = await runStore.findRuns({
138-
where: { id: { in: runIds } },
139+
where: { id: { in: boundedIn(runIds) } },
139140
select: { id: true, friendlyId: true, status: true },
140141
});
141142
for (const run of runs) {

apps/webapp/app/presenters/v3/QueueListPresenter.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RunEngine } from "@internal/run-engine";
22
import type { Prisma } from "@trigger.dev/database";
3-
import { TaskQueueType } from "@trigger.dev/database";
3+
import { TaskQueueType, boundedIn } from "@trigger.dev/database";
44
import { type PrismaClientOrTransaction } from "~/db.server";
55
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
66
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
@@ -289,7 +289,7 @@ export class QueueListPresenter extends BasePresenter {
289289
// AND keeps the search's name filter intact alongside the exclusion (a spread
290290
// would overwrite one name condition with the other).
291291
tailQueues = await this._replica.taskQueue.findMany({
292-
where: { AND: [where, { name: { notIn: excludedNames } }] },
292+
where: { AND: [where, { name: { notIn: boundedIn(excludedNames) } }] },
293293
select: queueListSelect,
294294
orderBy: {
295295
orderableName: "asc",
@@ -321,7 +321,7 @@ export class QueueListPresenter extends BasePresenter {
321321
return [];
322322
}
323323
const queues = await this._replica.taskQueue.findMany({
324-
where: { AND: [where, { name: { in: names } }] },
324+
where: { AND: [where, { name: { in: boundedIn(names) } }] },
325325
select: queueListSelect,
326326
});
327327
const byName = new Map(queues.map((queue) => [queue.name, queue]));
@@ -401,7 +401,7 @@ export class QueueListPresenter extends BasePresenter {
401401
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
402402
const overriddenByUsers = await this._replica.user.findMany({
403403
where: {
404-
id: { in: overriddenByIds },
404+
id: { in: boundedIn(overriddenByIds) },
405405
},
406406
});
407407

apps/webapp/app/presenters/v3/SessionListPresenter.server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { type Span } from "@opentelemetry/api";
22
import { type ClickHouse } from "@internal/clickhouse";
3-
import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
3+
import {
4+
type PrismaClient,
5+
type PrismaClientOrTransaction,
6+
boundedIn,
7+
} from "@trigger.dev/database";
48
import { type Direction } from "~/components/ListPagination";
59
import { timeFilters } from "~/components/runs/v3/SharedFilters";
610
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
@@ -188,7 +192,7 @@ export class SessionListPresenter {
188192
? runStore.findRuns(
189193
{
190194
where: {
191-
id: { in: currentRunIds },
195+
id: { in: boundedIn(currentRunIds) },
192196
projectId,
193197
runtimeEnvironmentId: environmentId,
194198
},

apps/webapp/app/presenters/v3/SessionPresenter.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type Span } from "@opentelemetry/api";
2-
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
2+
import { type PrismaClientOrTransaction, boundedIn } from "@trigger.dev/database";
33
import { env } from "~/env.server";
44
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
55
import { chatSnapshotStorageKey } from "~/services/realtime/chatSnapshot.server";
@@ -90,7 +90,7 @@ export class SessionPresenter {
9090
return runIds.length > 0
9191
? runStore.findRuns(
9292
{
93-
where: { id: { in: runIds } },
93+
where: { id: { in: boundedIn(runIds) } },
9494
select: { id: true, friendlyId: true, status: true },
9595
},
9696
this.replica

0 commit comments

Comments
 (0)