+ {/* Responsive to the side panel width (container query, not viewport): 3-up while there's
+ room, stacking to a single column only once the panel gets narrow. */}
+
@@ -1323,7 +1325,10 @@ function WaitingInQueueBlock({
: "of ∞"
}
/>
-
+
diff --git a/apps/webapp/app/routes/resources.queues.concurrency-keys.ts b/apps/webapp/app/routes/resources.queues.concurrency-keys.ts
new file mode 100644
index 0000000000..6ae479789f
--- /dev/null
+++ b/apps/webapp/app/routes/resources.queues.concurrency-keys.ts
@@ -0,0 +1,180 @@
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
+import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
+import { findEnvironmentById, hasAccessToEnvironment } from "~/models/runtimeEnvironment.server";
+import { requireUserId } from "~/services/session.server";
+import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
+import { engine } from "~/v3/runEngine.server";
+
+// One page of a queue's concurrency keys. The ClickHouse tier (queue_metrics_ck_v1) is the
+// paginated authority — ranked by peak backlog over the window with the total on every row
+// (single scan) — and the ≤PER_PAGE keys on the page are enriched with live "now" counts from
+// Redis (O(page), independent of total key cardinality). This replaces the old top-50 cap.
+export const CONCURRENCY_KEYS_PER_PAGE = 25;
+
+// Matches QUEUE_METRICS_DEFAULT_PERIOD (the detail page's TimeFilter default).
+const DEFAULT_PERIOD = "1d";
+
+const Body = z.object({
+ organizationId: z.string(),
+ projectId: z.string(),
+ environmentId: z.string(),
+ /** The queue's full name (e.g. `task/my-task` or a custom queue name). */
+ queueName: z.string(),
+ period: z.string().nullish(),
+ from: z.string().nullish(),
+ to: z.string().nullish(),
+ /** Case-insensitive substring filter on the key. */
+ search: z.string().nullish(),
+ page: z.number().int().min(1).default(1),
+});
+
+export type ConcurrencyKeyRow = {
+ key: string;
+ queued: number;
+ running: number;
+ oldestWaitMs: number | null;
+ started: number;
+ peakBacklog: number;
+ peakRunning: number;
+ meanWaitMs: number;
+};
+
+export type ConcurrencyKeysResponse =
+ | {
+ success: true;
+ rows: ConcurrencyKeyRow[];
+ total: number;
+ page: number;
+ perPage: number;
+ loadedAt: number;
+ }
+ | { success: false; error: string };
+
+// Snap the window to a minute grid so repeated loads within a bucket produce identical query
+// params and share ClickHouse query-cache entries (same trick as the queue-list ranking).
+function formatClickhouseDateTime(date: Date): string {
+ return date.toISOString().slice(0, 19).replace("T", " ");
+}
+function floorToMinute(ms: number): number {
+ return Math.floor(ms / 60_000) * 60_000;
+}
+function ceilToMinute(ms: number): number {
+ return Math.ceil(ms / 60_000) * 60_000;
+}
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+ const userId = await requireUserId(request);
+
+ const submission = Body.safeParse(await request.json());
+ if (!submission.success) {
+ return json(
+ { success: false, error: "Invalid input" },
+ { status: 400 }
+ );
+ }
+ const { organizationId, projectId, environmentId, queueName, period, from, to, search, page } =
+ submission.data;
+
+ const hasAccess = await hasAccessToEnvironment({
+ environmentId,
+ projectId,
+ organizationId,
+ userId,
+ });
+ if (!hasAccess) {
+ return json(
+ { success: false, error: "You don't have permission for this resource" },
+ { status: 403 }
+ );
+ }
+
+ // Needed as the tenant scope for the live Redis lookup (organization/project/environment ids).
+ const environment = await findEnvironmentById(environmentId);
+ if (!environment) {
+ return json(
+ { success: false, error: "Environment not found" },
+ { status: 404 }
+ );
+ }
+
+ // Gate on the per-org Queue Metrics UI flag, matching the queue detail page and run inspector, so
+ // this endpoint's data isn't reachable for orgs that can't see the UI. 404 (not 403) to hide it.
+ if (
+ !(await canAccessQueueMetricsUi({
+ userId,
+ organizationSlug: environment.organization.slug,
+ }))
+ ) {
+ return json({ success: false, error: "Not found" }, { status: 404 });
+ }
+
+ const range = timeFilterFromTo({
+ period: period ?? undefined,
+ from: from ?? undefined,
+ to: to ?? undefined,
+ defaultPeriod: DEFAULT_PERIOD,
+ });
+ const startTime = formatClickhouseDateTime(new Date(floorToMinute(range.from.getTime())));
+ const endTime = formatClickhouseDateTime(new Date(ceilToMinute(range.to.getTime())));
+
+ try {
+ const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
+ organizationId,
+ "query"
+ );
+
+ const [rankingError, rankingRows] = await clickhouse.queueMetrics.concurrencyKeyRanking({
+ organizationId,
+ projectId,
+ environmentId,
+ queueName,
+ startTime,
+ endTime,
+ nameContains: search?.trim() ?? "",
+ limit: CONCURRENCY_KEYS_PER_PAGE,
+ offset: (page - 1) * CONCURRENCY_KEYS_PER_PAGE,
+ });
+ if (rankingError) {
+ throw rankingError;
+ }
+
+ const total = rankingRows?.[0]?.ranked_total ?? 0;
+ const keys = (rankingRows ?? []).map((r) => r.concurrency_key);
+
+ // Enrich just this page's keys with live "now" counts from Redis.
+ const live = await engine.concurrencyKeyLiveStats(environment, queueName, keys);
+ const loadedAt = Date.now();
+
+ const rows: ConcurrencyKeyRow[] = (rankingRows ?? []).map((r) => {
+ const l = live.get(r.concurrency_key);
+ const oldestWaitMs =
+ l && l.oldestEnqueuedAt != null ? Math.max(0, loadedAt - l.oldestEnqueuedAt) : null;
+ return {
+ key: r.concurrency_key,
+ queued: l?.queued ?? 0,
+ running: l?.running ?? 0,
+ oldestWaitMs,
+ started: r.started,
+ peakBacklog: r.peak_backlog,
+ peakRunning: r.peak_running,
+ meanWaitMs: r.mean_wait_ms,
+ };
+ });
+
+ return json({
+ success: true,
+ rows,
+ total,
+ page,
+ perPage: CONCURRENCY_KEYS_PER_PAGE,
+ loadedAt,
+ });
+ } catch (error) {
+ return json(
+ { success: false, error: error instanceof Error ? error.message : "Query failed" },
+ { status: 400 }
+ );
+ }
+};
diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
index 23d20e108f..59da5d9b47 100644
--- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
+++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
@@ -62,12 +62,20 @@ if (workloadCreatedAtGateEnabled && !workloadTokenCutoff) {
type WorkloadGateAction = "start" | "complete" | "continue" | "snapshots_since";
-const workloadAuthGateCounter = new Counter({
- name: "workload_auth_gate_total",
- help: "Deployment token authorization outcomes on worker actions",
- labelNames: ["outcome", "action"] as const,
- registers: [metricsRegister],
-});
+// Wrapped in singleton() so a dev HMR re-eval of this module reuses the existing counter instead
+// of calling `new Counter` again — prom-client throws "already registered" on the second
+// registration, which crashes the dev server. Matches authenticatedWorkerInstanceCache above; a
+// no-op in prod (the module evaluates once).
+const workloadAuthGateCounter = singleton(
+ "workloadAuthGateCounter",
+ () =>
+ new Counter({
+ name: "workload_auth_gate_total",
+ help: "Deployment token authorization outcomes on worker actions",
+ labelNames: ["outcome", "action"] as const,
+ registers: [metricsRegister],
+ })
+);
function createAuthenticatedWorkerInstanceCache() {
return createCache({
diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts
index 1481be3653..49d2d7c02b 100644
--- a/internal-packages/clickhouse/src/index.ts
+++ b/internal-packages/clickhouse/src/index.ts
@@ -40,6 +40,7 @@ import {
getQueueRanking,
getQueueRankingNames,
getQueueRankingCount,
+ getConcurrencyKeyRanking,
} from "./queueMetrics.js";
import {
getSessionTagsQueryBuilder,
@@ -279,6 +280,7 @@ export class ClickHouse {
ranking: getQueueRanking(this.reader),
rankingNames: getQueueRankingNames(this.reader),
rankingCount: getQueueRankingCount(this.reader),
+ concurrencyKeyRanking: getConcurrencyKeyRanking(this.reader),
};
}
diff --git a/internal-packages/clickhouse/src/queueMetrics.ts b/internal-packages/clickhouse/src/queueMetrics.ts
index d86645e515..39576b4a0a 100644
--- a/internal-packages/clickhouse/src/queueMetrics.ts
+++ b/internal-packages/clickhouse/src/queueMetrics.ts
@@ -219,4 +219,76 @@ export function getQueueRankingCount(reader: ClickhouseReader) {
});
}
+// --- Per-concurrency-key ranking (the queue detail "Concurrency keys" table) ---
+
+const ConcurrencyKeyRankingParams = z.object({
+ organizationId: z.string(),
+ projectId: z.string(),
+ environmentId: z.string(),
+ queueName: z.string(),
+ startTime: z.string(),
+ endTime: z.string(),
+ /** Case-insensitive substring filter on the key ('' = no filter). */
+ nameContains: z.string(),
+ limit: z.number(),
+ offset: z.number(),
+});
+
+const ConcurrencyKeyRankingRow = z.object({
+ concurrency_key: z.string(),
+ started: z.coerce.number(),
+ peak_backlog: z.coerce.number(),
+ peak_running: z.coerce.number(),
+ mean_wait_ms: z.coerce.number(),
+ ranked_total: z.coerce.number(),
+});
+
+// The per-key table (queue_metrics_ck_v1) is activity-bound and its ORDER BY starts with the
+// tenant + queue, so filtering to one queue prunes to a contiguous index range — the aggregate
+// is bounded by real activity, never by total key cardinality. There is no per-key 5m rollup,
+// so this reads the 10s tier directly (the pre-existing LIMIT-50 query did the same).
+const CK_RANKING_WHERE = `organization_id = {organizationId: String}
+ AND project_id = {projectId: String}
+ AND environment_id = {environmentId: String}
+ AND queue_name = {queueName: String}
+ AND bucket_start >= {startTime: DateTime}
+ AND bucket_start < {endTime: DateTime}
+ AND ({nameContains: String} = '' OR positionCaseInsensitive(concurrency_key, {nameContains: String}) > 0)`;
+
+/**
+ * One page of a queue's concurrency keys ranked by peak backlog over the window, with the total
+ * ranked-key count on every row (window function) so page + count cost a single scan — the same
+ * shape as getQueueRanking. The `concurrency_key ASC` tiebreak makes OFFSET paging stable across
+ * keys that share a peak. Range stats (started/peak_backlog/peak_running/mean wait) come back on
+ * the same rows; live "now" counts are enriched per page from Redis by the caller.
+ */
+export function getConcurrencyKeyRanking(reader: ClickhouseReader) {
+ return reader.query({
+ name: "getConcurrencyKeyRanking",
+ query: `SELECT
+ concurrency_key,
+ started,
+ peak_backlog,
+ peak_running,
+ mean_wait_ms,
+ count() OVER () AS ranked_total
+ FROM (
+ SELECT
+ concurrency_key,
+ deltaSumTimestampMerge(started_delta) AS started,
+ max(max_queued) AS peak_backlog,
+ max(max_running) AS peak_running,
+ if(sum(wait_ms_count) > 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS mean_wait_ms
+ FROM trigger_dev.queue_metrics_ck_v1
+ WHERE ${CK_RANKING_WHERE}
+ GROUP BY concurrency_key
+ ORDER BY peak_backlog DESC, concurrency_key ASC
+ )
+ LIMIT {limit: UInt32} OFFSET {offset: UInt32}`,
+ params: ConcurrencyKeyRankingParams,
+ schema: ConcurrencyKeyRankingRow,
+ settings: QUEUE_METRICS_CACHE_SETTINGS,
+ });
+}
+
// (per-queue detail series is now fetched via TRQL + fillGaps from the metric resource route)
diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts
index ce5a55b215..e7853ee83c 100644
--- a/internal-packages/run-engine/src/engine/index.ts
+++ b/internal-packages/run-engine/src/engine/index.ts
@@ -1660,6 +1660,14 @@ export class RunEngine {
return this.runQueue.concurrencyKeyBreakdown(environment, queue, options);
}
+ async concurrencyKeyLiveStats(
+ environment: MinimalAuthenticatedEnvironment,
+ queue: string,
+ concurrencyKeys: string[]
+ ) {
+ return this.runQueue.concurrencyKeyLiveStats(environment, queue, concurrencyKeys);
+ }
+
async removeEnvironmentQueuesFromMasterQueue({
runtimeEnvironmentId,
organizationId,
diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts
index 4e6ca89d84..58225cc505 100644
--- a/internal-packages/run-engine/src/run-queue/index.ts
+++ b/internal-packages/run-engine/src/run-queue/index.ts
@@ -613,6 +613,50 @@ export class RunQueue {
return { totalBackloggedKeys, keys };
}
+ /**
+ * Live "now" stats for a specific set of concurrency keys — the current page of the paginated
+ * per-key table. Unlike concurrencyKeyBreakdown (which reads the top of the ckIndex), this
+ * targets exactly the given keys, so the table can enrich its ClickHouse-ranked page without
+ * scanning the whole index: O(keys) via one pipeline, independent of total key cardinality.
+ * Keys with no live backlog come back as zeros with a null oldest-enqueue time.
+ */
+ public async concurrencyKeyLiveStats(
+ env: MinimalAuthenticatedEnvironment,
+ queue: string,
+ concurrencyKeys: string[]
+ ): Promise