Skip to content

Commit fef8edb

Browse files
committed
feat(run-engine,clickhouse,webapp): concurrency-key health metrics and live per-key breakdown
CK queues now emit two extra gauge fields from the CK-path Lua scripts: the number of concurrency keys with queued runs (ZCARD of the ckIndex) and the head-of-line wait of the most-starved key (now minus the oldest ckIndex score). Both flow through the existing stream into new max-aggregated columns on the 10s and 5m tiers, and non-CK scripts keep the 7-field gauge shape. The queue detail page grows a concurrency-keys section for queues with CK activity: charts for backlogged keys and most-starved wait, plus a live per-key table (queued, running, oldest wait) read from the ckIndex zset, most starved first. Per-key history is intentionally not stored: key values are user-controlled, so the per-key dimension stays in Redis where it is bounded by the live backlog. The queue simulator gains a tenant-hotspot scenario that stages the CK gauge columns and a live ckIndex so the per-key table and charts render with data.
1 parent b7f943d commit fef8edb

14 files changed

Lines changed: 507 additions & 17 deletions

File tree

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import {
1919
import { findProjectBySlug } from "~/models/project.server";
2020
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
2121
import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server";
22+
import {
23+
Table,
24+
TableBody,
25+
TableCell,
26+
TableHeader,
27+
TableHeaderCell,
28+
TableRow,
29+
} from "~/components/primitives/Table";
30+
import { engine } from "~/v3/runEngine.server";
2231
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
2332
import { useSearchParams } from "~/hooks/useSearchParam";
2433
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
@@ -58,12 +67,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5867
const queue = retrieve.queue;
5968
const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name;
6069

70+
const ckBreakdown = await engine.concurrencyKeyBreakdown(environment, fullName, {
71+
limit: CK_LIVE_LIMIT,
72+
});
73+
6174
// Charts + CH-derived stats are fetched client-side per card (see QueueDetailChartCard /
6275
// useQueueMetric) so the drill-down renders instantly. The loader only returns the live
6376
// "now" counts + identifiers the client fetches need.
6477
return typedjson({
6578
queue,
6679
fullName,
80+
ckBreakdown,
81+
loadedAt: Date.now(),
6782
backPath: url.pathname.replace(/\/[^/]+$/, ""),
6883
ids: {
6984
organizationId: environment.organizationId,
@@ -81,16 +96,21 @@ const COLORS = {
8196
p95: "#F59E0B",
8297
p99: "#EF4444",
8398
throttled: "#F59E0B",
99+
ckKeys: "#34D399",
100+
ckWait: "#F59E0B",
84101
};
85102

103+
const CK_LIVE_LIMIT = 50;
104+
86105
type Ids = { organizationId: string; projectId: string; environmentId: string };
87106

88107
type TimeRangeParams = MetricResourceTimeRange;
89108

90109
const QUEUE_METRICS_DEFAULT_PERIOD = "1d";
91110

92111
export default function Page() {
93-
const { queue, fullName, backPath, ids } = useTypedLoaderData<typeof loader>();
112+
const { queue, fullName, ckBreakdown, loadedAt, backPath, ids } =
113+
useTypedLoaderData<typeof loader>();
94114
const plan = useCurrentPlan();
95115
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
96116

@@ -169,12 +189,115 @@ export default function Page() {
169189
queueName={fullName}
170190
series={[{ key: "throttled", label: "Throttled", color: COLORS.throttled }]}
171191
/>
192+
<ConcurrencyKeysSection
193+
breakdown={ckBreakdown}
194+
loadedAt={loadedAt}
195+
ids={ids}
196+
timeRange={timeRange}
197+
queueName={fullName}
198+
/>
172199
</div>
173200
</PageBody>
174201
</PageContainer>
175202
);
176203
}
177204

205+
type CkBreakdown = {
206+
totalBackloggedKeys: number;
207+
keys: Array<{
208+
concurrencyKey: string;
209+
queued: number;
210+
running: number;
211+
oldestEnqueuedAt: number;
212+
}>;
213+
};
214+
215+
// Rendered only for queues with concurrency-key activity: live keys in the ckIndex, or
216+
// nonzero CK history in the selected range (one cached scalar query decides).
217+
function ConcurrencyKeysSection({
218+
breakdown,
219+
loadedAt,
220+
ids,
221+
timeRange,
222+
queueName,
223+
}: {
224+
breakdown: CkBreakdown;
225+
loadedAt: number;
226+
ids: Ids;
227+
timeRange: TimeRangeParams;
228+
queueName: string;
229+
}) {
230+
const { rows, showLoading } = useQueueMetric(
231+
`SELECT max(max_ck_backlogged) AS peak_keys, max(max_ck_wait_ms) AS peak_wait\nFROM queue_metrics`,
232+
{ ids, timeRange, queueName }
233+
);
234+
const row = rows[0];
235+
const hasHistory = row ? toNumber(row.peak_keys) > 0 || toNumber(row.peak_wait) > 0 : false;
236+
const hasLive = breakdown.keys.length > 0;
237+
238+
if (!hasLive && (showLoading || !hasHistory)) return null;
239+
240+
return (
241+
<>
242+
<QueueDetailChartCard
243+
title="Concurrency keys with backlog"
244+
query={`SELECT timeBucket() AS t, max(max_ck_backlogged) AS keys\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
245+
fillGaps
246+
ids={ids}
247+
timeRange={timeRange}
248+
queueName={queueName}
249+
series={[{ key: "keys", label: "Keys", color: COLORS.ckKeys }]}
250+
/>
251+
<QueueDetailChartCard
252+
title="Most-starved key wait"
253+
query={`SELECT timeBucket() AS t, max(max_ck_wait_ms) AS wait\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
254+
fillGaps
255+
ids={ids}
256+
timeRange={timeRange}
257+
queueName={queueName}
258+
valueFormat={formatWaitMs}
259+
series={[{ key: "wait", label: "Max wait", color: COLORS.ckWait }]}
260+
/>
261+
{hasLive && (
262+
<div className="rounded-sm border border-grid-dimmed bg-background-bright">
263+
<div className="flex items-baseline justify-between px-3 pt-2">
264+
<div className="text-sm text-text-bright">Concurrency keys with queued runs</div>
265+
<div className="text-xs text-text-dimmed">
266+
{breakdown.totalBackloggedKeys > breakdown.keys.length
267+
? `Showing the ${breakdown.keys.length} most starved of ${breakdown.totalBackloggedKeys.toLocaleString()} keys`
268+
: `${breakdown.totalBackloggedKeys.toLocaleString()} ${
269+
breakdown.totalBackloggedKeys === 1 ? "key" : "keys"
270+
}`}
271+
</div>
272+
</div>
273+
<Table>
274+
<TableHeader>
275+
<TableRow>
276+
<TableHeaderCell>Key</TableHeaderCell>
277+
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
278+
<TableHeaderCell alignment="right">Running</TableHeaderCell>
279+
<TableHeaderCell alignment="right">Oldest wait</TableHeaderCell>
280+
</TableRow>
281+
</TableHeader>
282+
<TableBody>
283+
{breakdown.keys.map((key) => (
284+
<TableRow key={key.concurrencyKey}>
285+
<TableCell>{key.concurrencyKey}</TableCell>
286+
<TableCell alignment="right">{key.queued.toLocaleString()}</TableCell>
287+
<TableCell alignment="right">{key.running.toLocaleString()}</TableCell>
288+
<TableCell alignment="right">
289+
{formatWaitMs(Math.max(0, loadedAt - key.oldestEnqueuedAt))}
290+
</TableCell>
291+
</TableRow>
292+
))}
293+
</TableBody>
294+
</Table>
295+
</div>
296+
)}
297+
</>
298+
);
299+
}
300+
178301
function useQueueMetric(
179302
query: string,
180303
opts: { ids: Ids; timeRange: TimeRangeParams; queueName: string; fillGaps?: boolean }

apps/webapp/app/v3/querySchemas.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,22 @@ export const queueMetricsSchema: TableSchema = {
774774
fillMode: "carry",
775775
}),
776776
},
777+
max_ck_backlogged: {
778+
name: "max_ck_backlogged",
779+
...column("UInt32", {
780+
description:
781+
"Peak number of distinct concurrency keys with queued runs in the bucket. Aggregate with max(). Zero for queues that do not use concurrency keys.",
782+
fillMode: "carry",
783+
}),
784+
},
785+
max_ck_wait_ms: {
786+
name: "max_ck_wait_ms",
787+
...column("UInt32", {
788+
description:
789+
"Worst head-of-line wait (ms) across concurrency keys in the bucket: how long the most-starved key's oldest queued run has been waiting. Aggregate with max(). Zero for queues that do not use concurrency keys.",
790+
fillMode: "carry",
791+
}),
792+
},
777793
wait_ms_sum: {
778794
name: "wait_ms_sum",
779795
...column("UInt64", {

apps/webapp/app/v3/queueMetricsMapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export function mapEntryToRow(
9595
row.env_running = num(f.ec);
9696
row.env_limit = num(f.elim);
9797
row.throttled = num(f.thr);
98+
row.ck_backlogged = num(f.ckq);
99+
row.ck_max_wait_ms = num(f.ckw);
98100
} else {
99101
// Counter op: the monotonic odometer reading + its ordering key (and wait on started).
100102
row.cumulative = num(f.cum);

apps/webapp/seed-queue-metrics.mts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type QueueProfile = {
1919
arrivals: (bucket: number, rng: Rng) => number; // expected new runs enqueued this bucket
2020
waitBaseMs: number;
2121
sparse?: boolean; // emit no rows when the queue is fully idle (tests carry-forward gaps)
22+
// Concurrency-key queue: adds CK-health gauge fields + live ckIndex staging (--usage)
23+
ck?: {
24+
backlogged: (bucket: number, rng: Rng) => number;
25+
maxWaitMs: (bucket: number, rng: Rng) => number;
26+
};
2227
};
2328
type Scenario = {
2429
description: string;
@@ -218,6 +223,31 @@ const scenarios: Record<string, (totalBuckets: number, bucketSec: number) => Sce
218223
],
219224
}),
220225

226+
// Per-tenant concurrency keys: a hog tenant periodically floods the queue and starves
227+
// the others, so the CK charts (keys with backlog, most-starved wait) and the live
228+
// per-key table on the queue detail page have something to show. Use with --usage.
229+
"tenant-hotspot": () => ({
230+
description:
231+
"CK queue where a hog tenant starves others: CK charts + live key table (use --usage)",
232+
envLimit: () => 40,
233+
queues: [
234+
{
235+
name: "per-tenant",
236+
limit: () => 10,
237+
arrivals: (b, r) => poisson(b % 60 < 20 ? 25 : 8, r),
238+
waitBaseMs: 60,
239+
ck: {
240+
backlogged: (b, r) => (b % 60 < 20 ? 6 + Math.round(r() * 6) : Math.round(r() * 3)),
241+
maxWaitMs: (b, r) =>
242+
b % 60 < 20
243+
? Math.round(lognormal(90_000, 0.5, r))
244+
: Math.round(lognormal(3_000, 0.6, r)),
245+
},
246+
},
247+
{ name: "background", limit: () => 10, arrivals: (_b, r) => poisson(5, r), waitBaseMs: 40 },
248+
],
249+
}),
250+
221251
// Default: one env with a variety of queue behaviours + occasional env saturation.
222252
mixed: (totalBuckets) => ({
223253
description: "variety of queue profiles in one env, with occasional env saturation",
@@ -355,6 +385,15 @@ function simulateBucket(
355385
continue; // fully idle: leave a gap so carry-forward is exercised
356386
}
357387

388+
// CK-health fields stay coherent with the depth: no queued runs means no backlogged keys.
389+
const ckBacklogged = profile.ck
390+
? queued[q] > 0
391+
? Math.max(1, Math.min(profile.ck.backlogged(bucket, rng), queued[q]))
392+
: 0
393+
: undefined;
394+
const ckMaxWaitMs =
395+
profile.ck && ckBacklogged ? Math.round(profile.ck.maxWaitMs(bucket, rng)) : undefined;
396+
358397
const gauge: QueueMetricsRawV1Input = {
359398
...ids,
360399
queue_name: profile.name,
@@ -367,6 +406,9 @@ function simulateBucket(
367406
env_queued: envQueued,
368407
env_limit: envLimit,
369408
throttled: queued[q] > 0 && (running[q] >= limit[q] || scale < 1) ? 1 : 0,
409+
...(ckBacklogged !== undefined
410+
? { ck_backlogged: ckBacklogged, ck_max_wait_ms: ckMaxWaitMs ?? 0 }
411+
: {}),
370412
};
371413
rows.push(gauge);
372414

@@ -464,10 +506,23 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
464506
const { createRedisClient } = await import("@internal/redis");
465507
const redis = createRedisClient({ host, port });
466508
const rng = mulberry32(seed + 1);
467-
const base = `engine:runqueue:{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:queue:`;
509+
const prefix = "engine:runqueue:";
510+
const logicalBase = `{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:queue:`;
511+
const base = `${prefix}${logicalBase}`;
468512
for (const [q, profile] of scenario.queues.entries()) {
469513
const key = `${base}${profile.name}:currentDequeued`;
470514
await redis.del(key);
515+
516+
// CK staging (ckIndex + per-key subqueues) feeds the live per-key table on the queue
517+
// detail page. Members are stored unprefixed, exactly like the run-queue Lua does.
518+
const ckIndexKey = `${base}${profile.name}:ckIndex`;
519+
const lengthCounterKey = `${base}${profile.name}:lengthCounter`;
520+
const staleMembers = await redis.zrange(ckIndexKey, 0, -1);
521+
for (const member of staleMembers) {
522+
await redis.del(`${prefix}${member}`, `${prefix}${member}:currentConcurrency`);
523+
}
524+
await redis.del(ckIndexKey, lengthCounterKey);
525+
471526
if (clear) continue;
472527
const limit = profile.limit(0);
473528
// First queue rides at/over its limit, the rest at 30-90%, sparse mostly idle.
@@ -481,6 +536,35 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear
481536
if (count > 0) {
482537
await redis.sadd(key, ...Array.from({ length: count }, (_v, i) => `sim_run_${i}`));
483538
}
539+
540+
if (profile.ck) {
541+
const now = Date.now();
542+
const tenants = 12;
543+
let totalCkQueued = 0;
544+
for (let t = 1; t <= tenants; t++) {
545+
const tenant = `tenant-${String(t).padStart(2, "0")}`;
546+
const member = `${logicalBase}${profile.name}:ck:${tenant}`;
547+
const hog = t === 1;
548+
const queuedCount = hog ? 40 : 1 + Math.round(rng() * 5);
549+
const runningCount = hog ? limit : Math.round(rng() * 2);
550+
const oldestAgeMs = hog ? 15 * 60_000 : 5_000 + Math.round(rng() * 55_000);
551+
const zargs: Array<string | number> = [];
552+
for (let i = 0; i < queuedCount; i++) {
553+
zargs.push(now - oldestAgeMs + i * 250, `sim_${tenant}_run_${i}`);
554+
}
555+
await redis.zadd(`${prefix}${member}`, ...zargs);
556+
if (runningCount > 0) {
557+
await redis.sadd(
558+
`${prefix}${member}:currentConcurrency`,
559+
...Array.from({ length: runningCount }, (_v, i) => `sim_${tenant}_running_${i}`)
560+
);
561+
}
562+
await redis.zadd(ckIndexKey, now - oldestAgeMs, member);
563+
totalCkQueued += queuedCount;
564+
}
565+
// The aggregate "Queued now" reads ZCARD(base) + this counter; keep them coherent.
566+
await redis.set(lengthCounterKey, totalCkQueued, "EX", 24 * 3600);
567+
}
484568
}
485569
await redis.quit();
486570
console.log(
@@ -588,7 +672,8 @@ ${lines.join("\n")}
588672
Example designer setup (one project per scenario):
589673
pnpm --filter webapp run db:seed:queue-metrics -- --scenario mixed --reset
590674
pnpm --filter webapp run db:seed:queue-metrics -- --scenario many-queues --project qm-many-queues --reset
591-
pnpm --filter webapp run db:seed:queue-metrics -- --scenario throttled-backlog --project qm-throttled --reset`);
675+
pnpm --filter webapp run db:seed:queue-metrics -- --scenario throttled-backlog --project qm-throttled --reset
676+
pnpm --filter webapp run db:seed:queue-metrics -- --scenario tenant-hotspot --project qm-tenants --usage --reset`);
592677
}
593678

594679
async function main() {

apps/webapp/test/queueMetricsMapping.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ describe("mapEntryToRow", () => {
100100
})
101101
);
102102
expect(row!.event_time).toBe("2023-11-14 22:13:20");
103+
// Non-CK gauges carry no CK-health tail.
104+
expect(row!.ck_backlogged).toBeUndefined();
105+
expect(row!.ck_max_wait_ms).toBeUndefined();
106+
});
107+
108+
it("maps the CK-health tail on gauges from CK paths", () => {
109+
const row = mapEntryToRow({
110+
id: "1700000000000-0",
111+
fields: { op: "gauge", q: `${q}:ck:tenant-1`, ql: "4", ckq: "3", ckw: "2500" },
112+
});
113+
expect(row).toEqual(
114+
expect.objectContaining({
115+
op: "gauge",
116+
queue_name: "task/t",
117+
queued: 4,
118+
ck_backlogged: 3,
119+
ck_max_wait_ms: 2500,
120+
})
121+
);
103122
});
104123

105124
it("maps started with wait_ms + cumulative and drops unknown ops", () => {

0 commit comments

Comments
 (0)