perf(webapp): skip queue search count (#3925)

### Summary

Queue searches previously executed both a count query and a page query
with identical filters. This PR switches filtered searches to `hasMore`
pagination, removing the extra count query while preserving existing
search behavior.

### Testing

cd apps/webapp && pnpm run test ./test/queueListSearch.test.ts --run
passes


### Changelog

Improve filtered queue search performance.
This commit is contained in:
Katia Bulatova
2026-06-14 00:19:19 +02:00
committed by GitHub
parent 034058bce1
commit 85d93ffe0e
8 changed files with 244 additions and 63 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Speed up queue search by skipping count on filtered queries and using hasMore pagination
@@ -4,17 +4,25 @@ import { Link, useLocation } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { LinkButton } from "./Buttons";
/** Pass `hasNextPage` when the total page count is unknown; use `showPageNumbers={false}` in that case. */
export function PaginationControls({
currentPage,
totalPages,
hasNextPage,
showPageNumbers = true,
}: {
currentPage: number;
totalPages: number;
/** When set, navigation uses this instead of `totalPages`. */
hasNextPage?: boolean;
showPageNumbers?: boolean;
}) {
const location = useLocation();
if (totalPages <= 1) {
const hasNextMode = hasNextPage !== undefined;
const showPagination = hasNextMode ? currentPage > 1 || hasNextPage : totalPages > 1;
const nextDisabled = hasNextMode ? !hasNextPage : currentPage === totalPages;
if (!showPagination) {
return null;
}
@@ -42,8 +50,8 @@ export function PaginationControls({
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
className={cn("px-2", currentPage !== totalPages ? "group" : "")}
disabled={nextDisabled}
className={cn("px-2", !nextDisabled ? "group" : "")}
/>
</>
) : (
@@ -66,23 +74,21 @@ export function PaginationControls({
<div
className={cn(
"order-2 h-6 w-px bg-charcoal-600 transition-colors peer-hover/next:bg-charcoal-550 peer-hover/prev:bg-charcoal-550",
currentPage === 1 && currentPage === totalPages && "opacity-30"
currentPage === 1 && nextDisabled && "opacity-30"
)}
/>
<div
className={cn("peer/next order-3", currentPage === totalPages && "pointer-events-none")}
>
<div className={cn("peer/next order-3", nextDisabled && "pointer-events-none")}>
<LinkButton
to={pageUrl(location, currentPage + 1)}
variant="secondary/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
disabled={nextDisabled}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
currentPage === totalPages && "cursor-not-allowed opacity-50"
nextDisabled && "cursor-not-allowed opacity-50"
)}
/>
</div>
@@ -1,11 +1,16 @@
import { TaskQueueType } from "@trigger.dev/database";
import type { RunEngine } from "@internal/run-engine";
import { Prisma, TaskQueueType } from "@trigger.dev/database";
import { type PrismaClientOrTransaction } from "~/db.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
import { toQueueItem } from "./QueueRetrievePresenter.server";
import type { QueueListPagination } from "./queueListPagination.server";
const DEFAULT_ITEMS_PER_PAGE = 25;
type QueueListEngine = Pick<RunEngine, "lengthOfQueues" | "currentConcurrencyOfQueues">;
export const QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE = 25;
const MAX_ITEMS_PER_PAGE = 100;
const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = {
@@ -13,12 +18,51 @@ const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = {
custom: TaskQueueType.NAMED,
};
const queueListSelect = {
friendlyId: true,
name: true,
orderableName: true,
concurrencyLimit: true,
concurrencyLimitBase: true,
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
type: true,
paused: true,
} satisfies Prisma.TaskQueueSelect;
function buildQueueListWhere(
environmentId: string,
query: string | undefined,
type: "task" | "custom" | undefined
): Prisma.TaskQueueWhereInput {
const trimmedQuery = query?.trim();
return {
runtimeEnvironmentId: environmentId,
version: "V2",
name: trimmedQuery
? {
contains: trimmedQuery,
mode: "insensitive",
}
: undefined,
type: type ? typeToDBQueueType[type] : undefined,
};
}
export class QueueListPresenter extends BasePresenter {
private readonly perPage: number;
private readonly engineClient: QueueListEngine;
constructor(perPage: number = DEFAULT_ITEMS_PER_PAGE) {
super();
constructor(
perPage: number = QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE,
prismaClient?: PrismaClientOrTransaction,
replicaClient?: PrismaClientOrTransaction,
engineClient: QueueListEngine = engine
) {
super(prismaClient, replicaClient);
this.perPage = Math.min(perPage, MAX_ITEMS_PER_PAGE);
this.engineClient = engineClient;
}
public async call({
@@ -33,26 +77,14 @@ export class QueueListPresenter extends BasePresenter {
perPage?: number;
type?: "task" | "custom";
}) {
const hasFilters = (query !== undefined && query.length > 0) || type !== undefined;
const hasFilters = Boolean(query?.trim()) || type !== undefined;
// Get total count for pagination
const totalQueues = await this._replica.taskQueue.count({
where: {
runtimeEnvironmentId: environment.id,
version: "V2",
name: query
? {
contains: query,
mode: "insensitive",
}
: undefined,
type: type ? typeToDBQueueType[type] : undefined,
},
});
//check the engine is the correct version
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
const totalQueues = await this._replica.taskQueue.count({
where: buildQueueListWhere(environment.id, query, type),
});
if (totalQueues === 0) {
const oldQueue = await this._replica.taskQueue.findFirst({
where: {
@@ -78,10 +110,30 @@ export class QueueListPresenter extends BasePresenter {
};
}
if (hasFilters) {
const { queues, hasMore } = await this.getFilteredQueues(environment, query, page, type);
return {
success: true as const,
queues,
pagination: {
mode: "filtered" as const,
currentPage: page,
hasMore,
},
hasFilters,
};
}
const totalQueues = await this._replica.taskQueue.count({
where: buildQueueListWhere(environment.id, query, type),
});
return {
success: true as const,
queues: await this.getQueuesWithPagination(environment, query, page, type),
queues: await this.getUnfilteredQueues(environment, page, type),
pagination: {
mode: "unfiltered" as const,
currentPage: page,
totalPages: Math.ceil(totalQueues / this.perPage),
count: totalQueues,
@@ -91,35 +143,38 @@ export class QueueListPresenter extends BasePresenter {
};
}
private async getQueuesWithPagination(
private async getFilteredQueues(
environment: AuthenticatedEnvironment,
query: string | undefined,
page: number,
type: "task" | "custom" | undefined
) {
const queues = await this._replica.taskQueue.findMany({
where: {
runtimeEnvironmentId: environment.id,
version: "V2",
name: query
? {
contains: query,
mode: "insensitive",
}
: undefined,
type: type ? typeToDBQueueType[type] : undefined,
},
select: {
friendlyId: true,
name: true,
orderableName: true,
concurrencyLimit: true,
concurrencyLimitBase: true,
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
type: true,
paused: true,
where: buildQueueListWhere(environment.id, query, type),
select: queueListSelect,
orderBy: {
orderableName: "asc",
},
skip: (page - 1) * this.perPage,
take: this.perPage + 1,
});
const hasMore = queues.length > this.perPage;
return {
queues: await this.enrichQueues(environment, queues.slice(0, this.perPage)),
hasMore,
};
}
private async getUnfilteredQueues(
environment: AuthenticatedEnvironment,
page: number,
type: "task" | "custom" | undefined
) {
const queues = await this._replica.taskQueue.findMany({
where: buildQueueListWhere(environment.id, undefined, type),
select: queueListSelect,
orderBy: {
orderableName: "asc",
},
@@ -127,12 +182,29 @@ export class QueueListPresenter extends BasePresenter {
take: this.perPage,
});
const results = await Promise.all([
engine.lengthOfQueues(
return this.enrichQueues(environment, queues);
}
private async enrichQueues(
environment: AuthenticatedEnvironment,
queues: {
friendlyId: string;
name: string;
orderableName: string | null;
concurrencyLimit: number | null;
concurrencyLimitBase: number | null;
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: string | null;
type: TaskQueueType;
paused: boolean;
}[]
) {
const [queuedByQueue, runningByQueue] = await Promise.all([
this.engineClient.lengthOfQueues(
environment,
queues.map((q) => q.name)
),
engine.currentConcurrencyOfQueues(
this.engineClient.currentConcurrencyOfQueues(
environment,
queues.map((q) => q.name)
),
@@ -149,14 +221,13 @@ export class QueueListPresenter extends BasePresenter {
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
// Transform queues to include running and queued counts
return queues.map((queue) =>
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: results[1][queue.name] ?? 0,
queued: results[0][queue.name] ?? 0,
running: runningByQueue[queue.name] ?? 0,
queued: queuedByQueue[queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
@@ -0,0 +1,43 @@
export type QueueListFilteredPagination = {
mode: "filtered";
currentPage: number;
hasMore: boolean;
};
export type QueueListUnfilteredPagination = {
mode: "unfiltered";
currentPage: number;
totalPages: number;
count: number;
};
export type QueueListPagination = QueueListFilteredPagination | QueueListUnfilteredPagination;
export type OffsetLimitPagination = {
currentPage: number;
totalPages: number;
count: number;
};
/** Maps presenter pagination to the public API / SDK offset-limit contract. */
export function toOffsetLimitQueueListPagination(
pagination: QueueListPagination,
options: { itemsOnPage: number; perPage: number }
): OffsetLimitPagination {
if (pagination.mode === "unfiltered") {
return {
currentPage: pagination.currentPage,
totalPages: pagination.totalPages,
count: pagination.count,
};
}
return {
currentPage: pagination.currentPage,
totalPages: pagination.hasMore ? pagination.currentPage + 1 : pagination.currentPage,
count:
(pagination.currentPage - 1) * options.perPage +
options.itemsOnPage +
(pagination.hasMore ? 1 : 0),
};
}
@@ -440,7 +440,10 @@ export default function Page() {
<QueueFilters />
<PaginationControls
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalPages={pagination.mode === "unfiltered" ? pagination.totalPages : 1}
hasNextPage={
pagination.mode === "filtered" ? pagination.hasMore : undefined
}
showPageNumbers={false}
/>
</div>
+15 -2
View File
@@ -1,7 +1,11 @@
import { json } from "@remix-run/server-runtime";
import { type QueueItem } from "@trigger.dev/core/v3";
import { z } from "zod";
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
import {
QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE,
QueueListPresenter,
} from "~/presenters/v3/QueueListPresenter.server";
import { toOffsetLimitQueueListPagination } from "~/presenters/v3/queueListPagination.server";
import { logger } from "~/services/logger.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -30,7 +34,16 @@ export const loader = createLoaderApiRoute(
}
const queues: QueueItem[] = result.queues;
return json({ data: queues, pagination: result.pagination }, { status: 200 });
return json(
{
data: queues,
pagination: toOffsetLimitQueueListPagination(result.pagination, {
itemsOnPage: queues.length,
perPage: searchParams.perPage ?? QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE,
}),
},
{ status: 200 }
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
@@ -64,7 +64,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
paused: queue.paused,
})),
currentPage: result.pagination.currentPage,
hasMore: result.pagination.currentPage < result.pagination.totalPages,
hasMore:
result.pagination.mode === "filtered"
? result.pagination.hasMore
: result.pagination.currentPage < result.pagination.totalPages,
hasFilters: result.hasFilters,
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { toOffsetLimitQueueListPagination } from "~/presenters/v3/queueListPagination.server";
describe("toOffsetLimitQueueListPagination", () => {
it("passes through unfiltered pagination unchanged", () => {
expect(
toOffsetLimitQueueListPagination(
{ mode: "unfiltered", currentPage: 2, totalPages: 4, count: 80 },
{ itemsOnPage: 25, perPage: 25 }
)
).toEqual({ currentPage: 2, totalPages: 4, count: 80 });
});
it("maps filtered pagination to the legacy offset-limit shape", () => {
expect(
toOffsetLimitQueueListPagination(
{ mode: "filtered", currentPage: 1, hasMore: true },
{ itemsOnPage: 25, perPage: 25 }
)
).toEqual({ currentPage: 1, totalPages: 2, count: 26 });
expect(
toOffsetLimitQueueListPagination(
{ mode: "filtered", currentPage: 1, hasMore: false },
{ itemsOnPage: 10, perPage: 25 }
)
).toEqual({ currentPage: 1, totalPages: 1, count: 10 });
expect(
toOffsetLimitQueueListPagination(
{ mode: "filtered", currentPage: 2, hasMore: false },
{ itemsOnPage: 5, perPage: 25 }
)
).toEqual({ currentPage: 2, totalPages: 2, count: 30 });
});
});