Skip to content

Commit d60696c

Browse files
committed
feat(clickhouse,tsql): queue metrics rollups and single-scan ranking
Adds two rollups fed from the raw landing table: a per-queue 5-minute tier and an environment-level 1-minute tier (gauges plus TDigest wait quantiles). Ranking now reads the 5m tier and returns the page and the ranked total in one windowed query instead of two scans. The 5m materialized view reads raw rather than cascading off the 10s table: deltaSumTimestamp states hold a single first/last segment, so merging states in an MV's hash-ordered GROUP BY double-counts bridging spans. For the same reason the env tier carries no counter columns, and env-wide counter totals must group by queue before summing.
1 parent 3b4722c commit d60696c

7 files changed

Lines changed: 364 additions & 15 deletions

File tree

internal-packages/clickhouse/schema/035_create_queue_metrics_v1.sql

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,113 @@ SELECT
9292
FROM trigger_dev.queue_metrics_raw_v1
9393
GROUP BY organization_id, project_id, environment_id, queue_name, bucket_start;
9494

95+
-- (4) Env-level 1m rollup (no queue dimension) for header tiles/saturation charts.
96+
-- No counter deltas on purpose: cross-queue deltaSumTimestamp state merges mix unrelated
97+
-- odometers (env totals must GROUP BY queue then sum). TDigest because an env-level
98+
-- reservoir absorbs every sample in the environment.
99+
CREATE TABLE IF NOT EXISTS trigger_dev.env_metrics_1m_v1
100+
(
101+
organization_id LowCardinality(String),
102+
project_id LowCardinality(String),
103+
environment_id String CODEC(ZSTD(1)),
104+
bucket_start DateTime CODEC(Delta(4), ZSTD(1)),
105+
106+
max_env_queued SimpleAggregateFunction(max, UInt32),
107+
max_env_running SimpleAggregateFunction(max, UInt32),
108+
max_env_limit SimpleAggregateFunction(max, UInt32),
109+
throttled_count SimpleAggregateFunction(sum, UInt64),
110+
111+
wait_ms_sum SimpleAggregateFunction(sum, UInt64),
112+
wait_ms_count SimpleAggregateFunction(sum, UInt64),
113+
wait_quantiles AggregateFunction(quantilesTDigest(0.5, 0.9, 0.95, 0.99), UInt32)
114+
)
115+
ENGINE = AggregatingMergeTree()
116+
PARTITION BY toDate(bucket_start)
117+
ORDER BY (organization_id, project_id, environment_id, bucket_start)
118+
TTL bucket_start + INTERVAL 30 DAY
119+
SETTINGS ttl_only_drop_parts = 1;
120+
121+
-- (5) MV: raw -> env rollup.
122+
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.env_metrics_1m_mv_v1
123+
TO trigger_dev.env_metrics_1m_v1 AS
124+
SELECT
125+
organization_id, project_id, environment_id,
126+
toStartOfInterval(event_time, INTERVAL 1 MINUTE) AS bucket_start,
127+
max(env_queued) AS max_env_queued,
128+
max(env_running) AS max_env_running,
129+
max(env_limit) AS max_env_limit,
130+
sum(throttled) AS throttled_count,
131+
sumIf(wait_ms, op = 'started') AS wait_ms_sum,
132+
countIf(op = 'started' AND wait_ms > 0) AS wait_ms_count,
133+
quantilesTDigestStateIf(0.5, 0.9, 0.95, 0.99)(wait_ms, op = 'started' AND wait_ms > 0) AS wait_quantiles
134+
FROM trigger_dev.queue_metrics_raw_v1
135+
GROUP BY organization_id, project_id, environment_id, bucket_start;
136+
137+
-- (6) Per-queue 5m rollup, exact column mirror of queue_metrics_v1, for ranking and
138+
-- env-wide GROUP BY queue reads at long ranges.
139+
CREATE TABLE IF NOT EXISTS trigger_dev.queue_metrics_5m_v1
140+
(
141+
organization_id LowCardinality(String),
142+
project_id LowCardinality(String),
143+
environment_id String CODEC(ZSTD(1)),
144+
queue_name String CODEC(ZSTD(1)),
145+
bucket_start DateTime CODEC(Delta(4), ZSTD(1)),
146+
147+
enqueue_delta AggregateFunction(deltaSumTimestamp, UInt64, UInt64),
148+
started_delta AggregateFunction(deltaSumTimestamp, UInt64, UInt64),
149+
ack_delta AggregateFunction(deltaSumTimestamp, UInt64, UInt64),
150+
nack_delta AggregateFunction(deltaSumTimestamp, UInt64, UInt64),
151+
dlq_delta AggregateFunction(deltaSumTimestamp, UInt64, UInt64),
152+
throttled_count SimpleAggregateFunction(sum, UInt64),
153+
154+
max_queued SimpleAggregateFunction(max, UInt32),
155+
max_running SimpleAggregateFunction(max, UInt32),
156+
max_limit SimpleAggregateFunction(max, UInt32),
157+
max_env_queued SimpleAggregateFunction(max, UInt32),
158+
max_env_running SimpleAggregateFunction(max, UInt32),
159+
max_env_limit SimpleAggregateFunction(max, UInt32),
160+
161+
wait_ms_sum SimpleAggregateFunction(sum, UInt64),
162+
wait_ms_count SimpleAggregateFunction(sum, UInt64),
163+
wait_quantiles AggregateFunction(quantiles(0.5, 0.9, 0.95, 0.99), UInt32)
164+
)
165+
ENGINE = AggregatingMergeTree()
166+
PARTITION BY toDate(bucket_start)
167+
ORDER BY (organization_id, project_id, environment_id, queue_name, bucket_start)
168+
TTL bucket_start + INTERVAL 30 DAY
169+
SETTINGS ttl_only_drop_parts = 1;
170+
171+
-- (7) MV: raw -> 5m rollup. MUST read raw, never cascade off queue_metrics_v1 with
172+
-- -MergeState: MV GROUP BY merges states in hash order, and out-of-time-order
173+
-- deltaSumTimestamp merges double-count bridging spans (verified 3x inflation).
174+
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.queue_metrics_5m_mv_v1
175+
TO trigger_dev.queue_metrics_5m_v1 AS
176+
SELECT
177+
organization_id, project_id, environment_id, queue_name,
178+
toStartOfInterval(event_time, INTERVAL 5 MINUTE) AS bucket_start,
179+
deltaSumTimestampStateIf(cumulative, order_key, op = 'enqueue') AS enqueue_delta,
180+
deltaSumTimestampStateIf(cumulative, order_key, op = 'started') AS started_delta,
181+
deltaSumTimestampStateIf(cumulative, order_key, op = 'ack') AS ack_delta,
182+
deltaSumTimestampStateIf(cumulative, order_key, op = 'nack') AS nack_delta,
183+
deltaSumTimestampStateIf(cumulative, order_key, op = 'dlq') AS dlq_delta,
184+
sum(throttled) AS throttled_count,
185+
max(queued) AS max_queued,
186+
max(running) AS max_running,
187+
max(queue_limit) AS max_limit,
188+
max(env_queued) AS max_env_queued,
189+
max(env_running) AS max_env_running,
190+
max(env_limit) AS max_env_limit,
191+
sumIf(wait_ms, op = 'started') AS wait_ms_sum,
192+
countIf(op = 'started' AND wait_ms > 0) AS wait_ms_count,
193+
quantilesStateIf(0.5, 0.9, 0.95, 0.99)(wait_ms, op = 'started' AND wait_ms > 0) AS wait_quantiles
194+
FROM trigger_dev.queue_metrics_raw_v1
195+
GROUP BY organization_id, project_id, environment_id, queue_name, bucket_start;
196+
95197
-- +goose Down
198+
DROP VIEW IF EXISTS trigger_dev.queue_metrics_5m_mv_v1;
199+
DROP TABLE IF EXISTS trigger_dev.queue_metrics_5m_v1;
200+
DROP VIEW IF EXISTS trigger_dev.env_metrics_1m_mv_v1;
201+
DROP TABLE IF EXISTS trigger_dev.env_metrics_1m_v1;
96202
DROP VIEW IF EXISTS trigger_dev.queue_metrics_mv_v1;
97203
DROP TABLE IF EXISTS trigger_dev.queue_metrics_v1;
98204
DROP TABLE IF EXISTS trigger_dev.queue_metrics_raw_v1;

internal-packages/clickhouse/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
insertQueueMetricsRaw,
3737
getQueueListMetricsSummary,
3838
getQueueDepthSparklines,
39-
getQueueRankingPage,
39+
getQueueRanking,
40+
getQueueRankingNames,
4041
getQueueRankingCount,
4142
} from "./queueMetrics.js";
4243
import {
@@ -273,7 +274,8 @@ export class ClickHouse {
273274
insertRaw: insertQueueMetricsRaw(this.writer),
274275
listSummary: getQueueListMetricsSummary(this.reader),
275276
depthSparklines: getQueueDepthSparklines(this.reader),
276-
rankingPage: getQueueRankingPage(this.reader),
277+
ranking: getQueueRanking(this.reader),
278+
rankingNames: getQueueRankingNames(this.reader),
277279
rankingCount: getQueueRankingCount(this.reader),
278280
};
279281
}

internal-packages/clickhouse/src/queueMetrics.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,122 @@ describe("queue_metrics_v1", () => {
213213
await ch.close();
214214
}
215215
);
216+
217+
clickhouseTest(
218+
"5m and env rollups agree with the 10s tier, and cross-queue totals sum per queue",
219+
async ({ clickhouseContainer }) => {
220+
const ch = new ClickHouse({ url: clickhouseContainer.getConnectionUrl(), name: "test" });
221+
222+
// Own org so the env-level read (no queue filter) stays isolated from other tests.
223+
const rollOrg = "org_qm_roll";
224+
const rows: QueueMetricsRawV1Input[] = [
225+
...counter("started", "roll-a", 7, [100, 150, 200, 250, 300, 350, 400]),
226+
...counter("started", "roll-b", 3, [500, 600, 700]),
227+
{ ...base("gauge", "roll-a"), running: 4, queued: 9, env_running: 30, env_limit: 50 },
228+
{ ...base("gauge", "roll-b"), running: 2, queued: 1, env_running: 45, env_limit: 50 },
229+
].map((row) => ({ ...row, organization_id: rollOrg }));
230+
const [insertError] = await ch.queueMetrics.insertRaw(rows, SYNC);
231+
expect(insertError).toBeNull();
232+
233+
const perQueue = (table: string) =>
234+
ch.reader.query({
235+
name: "per-queue-both-tiers",
236+
query: `SELECT queue_name, deltaSumTimestampMerge(started_delta) AS started
237+
FROM ${table}
238+
WHERE queue_name IN ('roll-a', 'roll-b')
239+
GROUP BY queue_name ORDER BY queue_name`,
240+
schema: z.object({ queue_name: z.string(), started: z.coerce.number() }),
241+
})({});
242+
const [e10, rows10] = await perQueue("trigger_dev.queue_metrics_v1");
243+
const [e5m, rows5m] = await perQueue("trigger_dev.queue_metrics_5m_v1");
244+
expect(e10).toBeNull();
245+
expect(e5m).toBeNull();
246+
expect(rows10).toEqual([
247+
{ queue_name: "roll-a", started: 7 },
248+
{ queue_name: "roll-b", started: 3 },
249+
]);
250+
expect(rows5m).toEqual(rows10);
251+
252+
// Env-wide totals: sum of per-queue merges (a single merge across queues would mix
253+
// odometers and double-count).
254+
const [envTotalError, envTotal] = await ch.reader.query({
255+
name: "env-total-per-queue-sum",
256+
query: `SELECT sum(started) AS started FROM (
257+
SELECT queue_name, deltaSumTimestampMerge(started_delta) AS started
258+
FROM trigger_dev.queue_metrics_5m_v1
259+
WHERE queue_name IN ('roll-a', 'roll-b')
260+
GROUP BY queue_name
261+
)`,
262+
schema: z.object({ started: z.coerce.number() }),
263+
})({});
264+
expect(envTotalError).toBeNull();
265+
expect(envTotal![0]!.started).toBe(10);
266+
267+
const [envError, envRows] = await ch.reader.query({
268+
name: "env-rollup-read",
269+
query: `SELECT
270+
max(max_env_running) AS max_env_running,
271+
max(max_env_limit) AS max_env_limit,
272+
round(quantilesTDigestMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[4]) AS wait_p99
273+
FROM trigger_dev.env_metrics_1m_v1
274+
WHERE organization_id = {org: String}`,
275+
schema: z.object({
276+
max_env_running: z.coerce.number(),
277+
max_env_limit: z.coerce.number(),
278+
wait_p99: z.coerce.number(),
279+
}),
280+
params: z.object({ org: z.string() }),
281+
})({ org: rollOrg });
282+
expect(envError).toBeNull();
283+
expect(envRows![0]!.max_env_running).toBe(45);
284+
expect(envRows![0]!.max_env_limit).toBe(50);
285+
expect(envRows![0]!.wait_p99).toBeGreaterThanOrEqual(600);
286+
expect(envRows![0]!.wait_p99).toBeLessThanOrEqual(1000);
287+
288+
await ch.close();
289+
}
290+
);
291+
292+
clickhouseTest(
293+
"merged ranking returns the page and the windowed total in one query",
294+
async ({ clickhouseContainer }) => {
295+
const ch = new ClickHouse({ url: clickhouseContainer.getConnectionUrl(), name: "test" });
296+
297+
const gauge = (queue: string, queued: number, running: number): QueueMetricsRawV1Input => ({
298+
...base("gauge", queue),
299+
queued,
300+
running,
301+
});
302+
const [insertError] = await ch.queueMetrics.insertRaw(
303+
[gauge("rank-low", 1, 0), gauge("rank-high", 50, 3), gauge("rank-mid", 10, 2)],
304+
SYNC
305+
);
306+
expect(insertError).toBeNull();
307+
308+
const args = {
309+
organizationId: ORG,
310+
projectId: PROJECT,
311+
environmentId: ENV,
312+
startTime: "2026-06-30 11:50:00",
313+
nameContains: "rank-",
314+
byQueuedOnly: 0,
315+
};
316+
const [pageError, page] = await ch.queueMetrics.ranking({ ...args, limit: 2, offset: 0 });
317+
expect(pageError).toBeNull();
318+
expect(page).toEqual([
319+
{ queue_name: "rank-high", ranked_total: 3 },
320+
{ queue_name: "rank-mid", ranked_total: 3 },
321+
]);
322+
323+
const [countError, count] = await ch.queueMetrics.rankingCount(args);
324+
expect(countError).toBeNull();
325+
expect(count![0]!.ranked).toBe(3);
326+
327+
const [namesError, names] = await ch.queueMetrics.rankingNames({ ...args, limit: 10 });
328+
expect(namesError).toBeNull();
329+
expect(names!.map((r) => r.queue_name)).toEqual(["rank-high", "rank-mid", "rank-low"]);
330+
331+
await ch.close();
332+
}
333+
);
216334
});

internal-packages/clickhouse/src/queueMetrics.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const QueueMetricsSummaryRow = z.object({
5050
started_count: z.coerce.number(),
5151
});
5252

53+
// Callers align window bounds to the bucket grid so repeated loads share cache entries.
54+
const QUEUE_METRICS_CACHE_SETTINGS = {
55+
use_query_cache: 1,
56+
query_cache_ttl: 30,
57+
} as const;
58+
5359
/** Per-queue rollups over a window, for a fixed set of queues (the visible page). */
5460
export function getQueueListMetricsSummary(reader: ClickhouseReader) {
5561
return reader.query({
@@ -70,6 +76,7 @@ export function getQueueListMetricsSummary(reader: ClickhouseReader) {
7076
GROUP BY queue_name`,
7177
params: QueueMetricsListParams,
7278
schema: QueueMetricsSummaryRow,
79+
settings: QUEUE_METRICS_CACHE_SETTINGS,
7380
});
7481
}
7582

@@ -102,6 +109,7 @@ export function getQueueDepthSparklines(reader: ClickhouseReader) {
102109
ORDER BY bucket`,
103110
params: QueueDepthSparklineParams,
104111
schema: QueueDepthSparklineRow,
112+
settings: QUEUE_METRICS_CACHE_SETTINGS,
105113
});
106114
}
107115

@@ -119,29 +127,61 @@ const QueueRankingParams = z.object({
119127

120128
const QueueRankingRow = z.object({
121129
queue_name: z.string(),
130+
ranked_total: z.coerce.number(),
122131
});
123132

133+
// Ranking reads the 5m rollup: a 15-minute window there costs ~30x fewer rows than the
134+
// 10s table.
124135
const RANKING_WHERE = `organization_id = {organizationId: String}
125136
AND project_id = {projectId: String}
126137
AND environment_id = {environmentId: String}
127138
AND bucket_start >= {startTime: DateTime}
128139
AND queue_name != '__overflow__'
129140
AND ({nameContains: String} = '' OR positionCaseInsensitive(queue_name, {nameContains: String}) > 0)`;
130141

131-
/** Queue names ranked by recent activity, for relevance-ordered list pages. */
132-
export function getQueueRankingPage(reader: ClickhouseReader) {
142+
/**
143+
* One page of queue names ranked by recent activity, with the total ranked count on
144+
* every row (window function), so page + count cost a single scan.
145+
*/
146+
export function getQueueRanking(reader: ClickhouseReader) {
133147
return reader.query({
134-
name: "getQueueRankingPage",
135-
query: `SELECT queue_name
136-
FROM trigger_dev.queue_metrics_v1
137-
WHERE ${RANKING_WHERE}
138-
GROUP BY queue_name
139-
ORDER BY
140-
if({byQueuedOnly: UInt8} = 1, max(max_queued), max(max_queued) + max(max_running)) DESC,
141-
queue_name ASC
148+
name: "getQueueRanking",
149+
query: `SELECT queue_name, count() OVER () AS ranked_total
150+
FROM (
151+
SELECT queue_name
152+
FROM trigger_dev.queue_metrics_5m_v1
153+
WHERE ${RANKING_WHERE}
154+
GROUP BY queue_name
155+
ORDER BY
156+
if({byQueuedOnly: UInt8} = 1, max(max_queued), max(max_queued) + max(max_running)) DESC,
157+
queue_name ASC
158+
)
142159
LIMIT {limit: UInt32} OFFSET {offset: UInt32}`,
143160
params: QueueRankingParams,
144161
schema: QueueRankingRow,
162+
settings: QUEUE_METRICS_CACHE_SETTINGS,
163+
});
164+
}
165+
166+
const QueueRankingNamesParams = QueueRankingParams.omit({ byQueuedOnly: true, offset: true });
167+
168+
const QueueRankingNameRow = z.object({
169+
queue_name: z.string(),
170+
});
171+
172+
/** All ranked queue names (activity order), used to exclude them from the alphabetical tail. */
173+
export function getQueueRankingNames(reader: ClickhouseReader) {
174+
return reader.query({
175+
name: "getQueueRankingNames",
176+
query: `SELECT queue_name
177+
FROM trigger_dev.queue_metrics_5m_v1
178+
WHERE ${RANKING_WHERE}
179+
GROUP BY queue_name
180+
ORDER BY max(max_queued) + max(max_running) DESC, queue_name ASC
181+
LIMIT {limit: UInt32}`,
182+
params: QueueRankingNamesParams,
183+
schema: QueueRankingNameRow,
184+
settings: QUEUE_METRICS_CACHE_SETTINGS,
145185
});
146186
}
147187

@@ -155,15 +195,16 @@ const QueueRankingCountRow = z.object({
155195
ranked: z.coerce.number(),
156196
});
157197

158-
/** How many queues have activity in the ranking window (the ranked head of the list). */
198+
/** Ranked-queue count alone, for pages past the ranked head (approximate uniq is fine). */
159199
export function getQueueRankingCount(reader: ClickhouseReader) {
160200
return reader.query({
161201
name: "getQueueRankingCount",
162-
query: `SELECT uniqExact(queue_name) AS ranked
163-
FROM trigger_dev.queue_metrics_v1
202+
query: `SELECT uniq(queue_name) AS ranked
203+
FROM trigger_dev.queue_metrics_5m_v1
164204
WHERE ${RANKING_WHERE}`,
165205
params: QueueRankingCountParams,
166206
schema: QueueRankingCountRow,
207+
settings: QUEUE_METRICS_CACHE_SETTINGS,
167208
});
168209
}
169210

internal-packages/tsql/src/query/functions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,13 @@ export const TSQL_AGGREGATIONS: Record<string, TSQLFunctionMeta> = {
645645
maxParams: 1,
646646
aggregate: true,
647647
},
648+
quantilesTDigestMerge: {
649+
clickhouseName: "quantilesTDigestMerge",
650+
minArgs: 1,
651+
maxArgs: 1,
652+
minParams: 1,
653+
aggregate: true,
654+
},
648655
sumMerge: { clickhouseName: "sumMerge", minArgs: 1, maxArgs: 1, aggregate: true },
649656
avgMerge: { clickhouseName: "avgMerge", minArgs: 1, maxArgs: 1, aggregate: true },
650657
countMerge: { clickhouseName: "countMerge", minArgs: 1, maxArgs: 1, aggregate: true },

0 commit comments

Comments
 (0)