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:
@@ -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),
|
||||
};
|
||||
}
|
||||
+4
-1
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+4
-1
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user