@@ -3,6 +3,8 @@ import type { Prisma } from "@trigger.dev/database";
33import { TaskQueueType } from "@trigger.dev/database" ;
44import { type PrismaClientOrTransaction } from "~/db.server" ;
55import { type AuthenticatedEnvironment } from "~/services/apiAuth.server" ;
6+ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server" ;
7+ import { logger } from "~/services/logger.server" ;
68import { determineEngineVersion } from "~/v3/engineVersion.server" ;
79import { engine } from "~/v3/runEngine.server" ;
810import { BasePresenter } from "./basePresenter.server" ;
@@ -13,6 +15,12 @@ type QueueListEngine = Pick<RunEngine, "lengthOfQueues" | "currentConcurrencyOfQ
1315export const QUEUE_LIST_DEFAULT_ITEMS_PER_PAGE = 25 ;
1416const MAX_ITEMS_PER_PAGE = 100 ;
1517
18+ export type QueueListSort = "busiest" | "queued" | "name" ;
19+
20+ /** Ranking reads recent aggregated gauges, so ordering is a stable snapshot, not a live sort. */
21+ export const QUEUE_RANKING_WINDOW_MINUTES = 15 ;
22+ const MAX_RANKED_QUEUES = 5000 ;
23+
1624const typeToDBQueueType : Record < "task" | "custom" , TaskQueueType > = {
1725 task : TaskQueueType . VIRTUAL ,
1826 custom : TaskQueueType . NAMED ,
@@ -30,6 +38,38 @@ const queueListSelect = {
3038 paused : true ,
3139} satisfies Prisma . TaskQueueSelect ;
3240
41+ type QueueListRow = Prisma . TaskQueueGetPayload < { select : typeof queueListSelect } > ;
42+
43+ type QueueListItem = ReturnType < typeof toQueueItem > ;
44+
45+ type QueueListPagination =
46+ | { mode : "filtered" ; currentPage : number ; hasMore : boolean }
47+ | { mode : "unfiltered" ; currentPage : number ; totalPages : number ; count : number } ;
48+
49+ // The `?: undefined` markers keep every key reachable across the union, so consumers
50+ // can destructure before narrowing on `success`.
51+ export type QueueListResult =
52+ | {
53+ success : false ;
54+ code : string ;
55+ totalQueues : number ;
56+ hasFilters : boolean ;
57+ queues ?: undefined ;
58+ pagination ?: undefined ;
59+ }
60+ | {
61+ success : true ;
62+ queues : QueueListItem [ ] ;
63+ pagination : QueueListPagination ;
64+ totalQueues ?: number ;
65+ hasFilters : boolean ;
66+ code ?: undefined ;
67+ } ;
68+
69+ function formatClickhouseDateTime ( date : Date ) : string {
70+ return date . toISOString ( ) . slice ( 0 , 19 ) . replace ( "T" , " " ) ;
71+ }
72+
3373function buildQueueListWhere (
3474 environmentId : string ,
3575 query : string | undefined ,
@@ -70,13 +110,15 @@ export class QueueListPresenter extends BasePresenter {
70110 query,
71111 page,
72112 type,
113+ sort = "name" ,
73114 } : {
74115 environment : AuthenticatedEnvironment ;
75116 query ?: string ;
76117 page : number ;
77118 perPage ?: number ;
78119 type ?: "task" | "custom" ;
79- } ) {
120+ sort ?: QueueListSort ;
121+ } ) : Promise < QueueListResult > {
80122 const hasFilters = Boolean ( query ?. trim ( ) ) || type !== undefined ;
81123
82124 const engineVersion = await determineEngineVersion ( { environment } ) ;
@@ -110,6 +152,18 @@ export class QueueListPresenter extends BasePresenter {
110152 } ;
111153 }
112154
155+ if ( sort !== "name" ) {
156+ // Ranking is additive: any failure or unsupported input falls back to name order.
157+ try {
158+ const ranked = await this . getRankedQueues ( environment , query , page , type , sort ) ;
159+ if ( ranked ) {
160+ return ranked ;
161+ }
162+ } catch ( error ) {
163+ logger . warn ( "Queue ranking unavailable, falling back to name order" , { error } ) ;
164+ }
165+ }
166+
113167 if ( hasFilters ) {
114168 const { queues, hasMore } = await this . getFilteredQueues ( environment , query , page , type ) ;
115169
@@ -143,6 +197,123 @@ export class QueueListPresenter extends BasePresenter {
143197 } ;
144198 }
145199
200+ /**
201+ * ClickHouse ranks queues by recent activity and returns the requested page of names;
202+ * queues with no recent metrics follow in name order. Null when ranking does not apply.
203+ */
204+ private async getRankedQueues (
205+ environment : AuthenticatedEnvironment ,
206+ query : string | undefined ,
207+ page : number ,
208+ type : "task" | "custom" | undefined ,
209+ sort : Exclude < QueueListSort , "name" >
210+ ) {
211+ if ( type !== undefined ) {
212+ return null ;
213+ }
214+
215+ const clickhouse = await clickhouseFactory . getClickhouseForOrganization (
216+ environment . organizationId ,
217+ "query"
218+ ) ;
219+
220+ const rankingArgs = {
221+ organizationId : environment . organizationId ,
222+ projectId : environment . projectId ,
223+ environmentId : environment . id ,
224+ startTime : formatClickhouseDateTime (
225+ new Date ( Date . now ( ) - QUEUE_RANKING_WINDOW_MINUTES * 60 * 1000 )
226+ ) ,
227+ nameContains : query ?. trim ( ) ?? "" ,
228+ } ;
229+
230+ const [ countError , countRows ] = await clickhouse . queueMetrics . rankingCount ( rankingArgs ) ;
231+ if ( countError ) {
232+ throw countError ;
233+ }
234+ const ranked = countRows ?. [ 0 ] ?. ranked ?? 0 ;
235+ if ( ranked > MAX_RANKED_QUEUES ) {
236+ return null ;
237+ }
238+
239+ const where = buildQueueListWhere ( environment . id , query , type ) ;
240+ const totalQueues = await this . _replica . taskQueue . count ( { where } ) ;
241+ const offset = ( page - 1 ) * this . perPage ;
242+
243+ let rankedPageQueues : QueueListRow [ ] = [ ] ;
244+ if ( offset < ranked ) {
245+ const [ pageError , pageRows ] = await clickhouse . queueMetrics . rankingPage ( {
246+ ...rankingArgs ,
247+ byQueuedOnly : sort === "queued" ? 1 : 0 ,
248+ limit : this . perPage ,
249+ offset,
250+ } ) ;
251+ if ( pageError ) {
252+ throw pageError ;
253+ }
254+ const rankedNames = ( pageRows ?? [ ] ) . map ( ( row ) => row . queue_name ) ;
255+ rankedPageQueues = await this . findQueuesByNames ( where , rankedNames ) ;
256+ }
257+
258+ // Tail of the page: name-ordered queues that have no recent metrics. Slot math uses the
259+ // ClickHouse counts so pages never overlap, even if some ranked names no longer exist.
260+ const rankedSlots = Math . min ( Math . max ( ranked - offset , 0 ) , this . perPage ) ;
261+ const tailNeeded = this . perPage - rankedSlots ;
262+ let tailQueues : QueueListRow [ ] = [ ] ;
263+ if ( tailNeeded > 0 ) {
264+ let excludedNames : string [ ] = [ ] ;
265+ if ( ranked > 0 ) {
266+ const [ allError , allRows ] = await clickhouse . queueMetrics . rankingPage ( {
267+ ...rankingArgs ,
268+ byQueuedOnly : 0 ,
269+ limit : MAX_RANKED_QUEUES ,
270+ offset : 0 ,
271+ } ) ;
272+ if ( allError ) {
273+ throw allError ;
274+ }
275+ excludedNames = ( allRows ?? [ ] ) . map ( ( row ) => row . queue_name ) ;
276+ }
277+ tailQueues = await this . _replica . taskQueue . findMany ( {
278+ where : { ...where , name : { notIn : excludedNames } } ,
279+ select : queueListSelect ,
280+ orderBy : {
281+ orderableName : "asc" ,
282+ } ,
283+ skip : Math . max ( 0 , offset - ranked ) ,
284+ take : tailNeeded ,
285+ } ) ;
286+ }
287+
288+ return {
289+ success : true as const ,
290+ queues : await this . enrichQueues ( environment , [ ...rankedPageQueues , ...tailQueues ] ) ,
291+ pagination : {
292+ mode : "unfiltered" as const ,
293+ currentPage : page ,
294+ totalPages : Math . max ( 1 , Math . ceil ( totalQueues / this . perPage ) ) ,
295+ count : totalQueues ,
296+ } ,
297+ totalQueues,
298+ hasFilters : Boolean ( query ?. trim ( ) ) || type !== undefined ,
299+ } ;
300+ }
301+
302+ private async findQueuesByNames (
303+ where : Prisma . TaskQueueWhereInput ,
304+ names : string [ ]
305+ ) : Promise < QueueListRow [ ] > {
306+ if ( names . length === 0 ) {
307+ return [ ] ;
308+ }
309+ const queues = await this . _replica . taskQueue . findMany ( {
310+ where : { ...where , name : { in : names } } ,
311+ select : queueListSelect ,
312+ } ) ;
313+ const byName = new Map ( queues . map ( ( queue ) => [ queue . name , queue ] ) ) ;
314+ return names . flatMap ( ( name ) => byName . get ( name ) ?? [ ] ) ;
315+ }
316+
146317 private async getFilteredQueues (
147318 environment : AuthenticatedEnvironment ,
148319 query : string | undefined ,
0 commit comments