From a6d96d8f84d75fb5f4a87521064e6f330412e210 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Fri, 12 Jun 2026 15:22:16 +0200 Subject: [PATCH] perf(webapp): skip queue search count Skip the count query when filtering queues and paginate search results with hasMore instead. --- .../fix-queue-search-performance.md | 6 + .../app/components/primitives/Pagination.tsx | 24 ++- .../v3/QueueListPresenter.server.ts | 171 +++++++++++++----- .../v3/queueListPagination.server.ts | 43 +++++ .../route.tsx | 5 +- apps/webapp/app/routes/api.v1.queues.ts | 17 +- ...ects.$projectParam.env.$envParam.queues.ts | 5 +- apps/webapp/test/queueListPagination.test.ts | 36 ++++ 8 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 .server-changes/fix-queue-search-performance.md create mode 100644 apps/webapp/app/presenters/v3/queueListPagination.server.ts create mode 100644 apps/webapp/test/queueListPagination.test.ts diff --git a/.server-changes/fix-queue-search-performance.md b/.server-changes/fix-queue-search-performance.md new file mode 100644 index 00000000000..70ff184f1f0 --- /dev/null +++ b/.server-changes/fix-queue-search-performance.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Speed up queue search by skipping count on filtered queries and using hasMore pagination diff --git a/apps/webapp/app/components/primitives/Pagination.tsx b/apps/webapp/app/components/primitives/Pagination.tsx index f465083710e..7354febc227 100644 --- a/apps/webapp/app/components/primitives/Pagination.tsx +++ b/apps/webapp/app/components/primitives/Pagination.tsx @@ -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({
-
+
diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 0fe9e3f3652..de88628a834 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -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; + +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; - - // 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, - }, - }); + const hasFilters = Boolean(query?.trim()) || 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, diff --git a/apps/webapp/app/presenters/v3/queueListPagination.server.ts b/apps/webapp/app/presenters/v3/queueListPagination.server.ts new file mode 100644 index 00000000000..b366ebe0f4e --- /dev/null +++ b/apps/webapp/app/presenters/v3/queueListPagination.server.ts @@ -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), + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index debab4683ce..b6afe46be4f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -440,7 +440,10 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/api.v1.queues.ts b/apps/webapp/app/routes/api.v1.queues.ts index 18c0f688370..0e976ec8134 100644 --- a/apps/webapp/app/routes/api.v1.queues.ts +++ b/apps/webapp/app/routes/api.v1.queues.ts @@ -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 }); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts index 4659271e9ec..98e31daa4d3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues.ts @@ -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, }; } diff --git a/apps/webapp/test/queueListPagination.test.ts b/apps/webapp/test/queueListPagination.test.ts new file mode 100644 index 00000000000..dea17ae30e4 --- /dev/null +++ b/apps/webapp/test/queueListPagination.test.ts @@ -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 }); + }); +});