Repository navigation
Description
Summary
For a PromQL source on a ClickHouse TimeSeries table, clickstack_describe_source is the documented way to find metric and label names (PROMQL_SOURCE_USAGE, the clickstack_promql description). It lists them for the last 24 hours. On a table with about 217M series, every call fails at the 8 GiB per-query memory limit, so agents have no discovery path at all.
The cost comes from the time filter, not from the names:
- SQL path (
timeseriesEngine.ts,getSeriesFilterConditions):SELECT DISTINCT metric_name FROM timeSeriesTags(db, metrics) WHERE id IN (SELECT id FROM timeSeriesTimeRanges(db, metrics) GROUP BY id HAVING min(min_time) <= <end> AND max(max_time) >= <start>), and the same witharrayJoin(arrayConcat(['__name__'], mapKeys(tags)))for label names. The innerGROUP BY idbuilds a hash table of every series id in the time ranges table, whatever the window. - ClickHouse Prometheus HTTP API path (new on
mainwith feat(api): serve PromQL label lookups from ClickHouse's Prometheus HTTP API #3628 / feat(mcp): read PromQL label catalog from ClickHouse's Prometheus HTTP API #3630, used on 26.9+ when theprometheus_api_v1handler is configured):/labelsand/label/__name__/valueswithstart/end. ClickHouse answers them with... FROM <tags inner table> ALL LEFT JOIN (SELECT id, 1 AS matched FROM <time ranges inner table> WHERE <window>) USING (id) ...plusgroupUniqArrayArray(timeSeriesIdToTags(...)), with noLIMIT. This joins every series too, and needs 2.5–2.7× the memory of the SQL path.
Listing the names without the time filter reads one LowCardinality column of the tags table, which is sorted by metric_name. It stays small at any size.
Environment
- HyperDX
mainat c220e16 (2026-11-13), ClickHouse 26.10.3.7 (CI compose). - Production-size check: ClickHouse 26.10.3, TimeSeries table (schema version 8) with about 217M series (217.5M tags rows, 219.9M time-range rows), 8 GiB per-query memory limit for the HyperDX user.
Steps to reproduce
reproPromqlCatalogScale.int.test.ts (below; copy to packages/api/src/mcp/__tests__/) creates a version 8 TimeSeries table with N series (default 1M, REPRO_SERIES=2000000 below), all active in the last hour. It calls clickstack_describe_source on a source pinned to table-function (SQL path) and on an auto source (HTTP API path on the CI ClickHouse), then reads system.query_log:
REPRO 2,000,000 series, 200 metric names
plain SELECT DISTINCT metric_name (tags table only) 5 ms rows=2000000 memory=4.3 MiB
describe_source, SQL path: 656 ms wall
metric names 576 ms rows=4000000 memory=315.7 MiB (73x plain)
label names 621 ms rows=4000000 memory=293.4 MiB (68x plain)
describe_source, prometheus_api_v1 handler: 3245 ms wall
metric names 3200 ms rows=4000000 memory=811.0 MiB (188x plain)
label names 3213 ms rows=4000000 memory=803.1 MiB (186x plain)
At 0.5M series the two paths used 80 MiB and 199 MiB per lookup, so memory grows linearly with the number of series on both. The two lookups run in parallel, so one call holds twice that.
Expected vs actual
On the 217M-series table:
| Time | Memory | Result | |
|---|---|---|---|
describe_source, SQL path (12 calls in 15 minutes, from 5 agents) |
4–6 s | 8 GiB limit hit | 12 of 12 lookups failed: 7 memory limit (241), 5 cancelled with their sibling (394); 57 GiB read in total. partial: true, no names |
| The label-values SQL with a 1-hour window, run by hand without the 8 GiB limit | 224 s | 14.7 GiB | 334M rows read |
SELECT DISTINCT metric_name FROM <tags table> |
196 ms | 4.4 MiB | 3,919 names (415 MiB of a LowCardinality column read, under 1 MiB on disk) |
Label names of one metric (WHERE metric_name = '…') |
7 ms | 3.4 MiB | uses the sort key |
We didn't run the HTTP API path on the large table. Locally it needs 2.5–2.7× the SQL path's memory, so it fails the same way.
Impact
clickstack_list_metrics and clickstack_describe_metric only take OTel metric sources, so describe_source is the only catalog for a PromQL source. With it failing, agents guessed metric names (several came back empty), or ran count by (__name__)({label="x"}). Without a metric name that selector reads the whole tags table: one such instant query hit the 20 GiB read cap, and another read 14.4 GiB. The failed catalog calls also cost the server: each one held up to 8 GiB for about 5 seconds, and in those 15 minutes server memory rose from 29 to 40 GiB.
The same GROUP BY id filter is used by the label routes (queryLabelNames / queryLabelValues) when no match[] is given.
Proposed fix
- Metric names without the time-ranges join. Read
SELECT DISTINCT metric_name FROM timeSeriesTags(...), with an optional name prefix or pattern and paging (aslist_metricshas for metric sources). A note can say that names may include series with no recent samples. If a time bound is wanted, apply it to the names that are returned, not to every series. - Label names per metric. Add an optional
metricName(ormatch) argument todescribe_sourcefor PromQL sources, and list that metric's label names withmetric_name = …on the tags table (the same idea as perf(api): read only the matched metrics' tags rows in PromQL label lookups #3603 formatch[]). Without it, return metric names only, or label names from a bounded read (max_rows_to_readwithread_overflow_mode = 'break'), marked as a sample. - Fail cheaply. Run the catalog lookups with a low
max_memory_usageandmax_execution_time(e.g. 1 GiB and 10 s), and when they fail, return a hint on how to narrow the lookup.
Happy to send a PR for 1 and 2.
Related
- feat(api): serve PromQL label lookups from ClickHouse's Prometheus HTTP API #3628 / feat(mcp): read PromQL label catalog from ClickHouse's Prometheus HTTP API #3630: label lookups and the MCP catalog through ClickHouse's Prometheus HTTP API
- PromQL label lookups with match[] read the whole TimeSeries tags table #3602 / perf(api): read only the matched metrics' tags rows in PromQL label lookups #3603: label lookups with
match[]pinmetric_name - PromQL label lookups fail on TimeSeries tables created by ClickHouse 26.10 (schema version 8) #3580 / fix(api): support TimeSeries time ranges tables (schema version 8) in label lookups #3581: label lookups on TimeSeries schema version 8
- feat: describe PromQL sources in MCP #3496: describe PromQL sources in MCP
reproPromqlCatalogScale.int.test.ts
// Repro: clickstack_describe_source on a PromQL (TimeSeries) source costs memory in proportion
// to the number of series in the table, on both label backends; a plain metric-name listing doesn't.
// Copy to packages/api/src/mcp/__tests__/ and run (REPRO_SERIES defaults to 1,000,000):
// REPRO_SERIES=2000000 make dev-int FILE=reproPromqlCatalogScale
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import * as config from '@/config';
import {
DEFAULT_DATABASE,
dropTimeSeriesTable,
executeTimeSeriesSqlCommand,
getLoggedInAgent,
getServer,
getTestFixtureClickHouseClient,
} from '@/fixtures';
import { clearBackendProbeCache } from '@/controllers/promql';
import Connection from '@/models/connection';
import { Source } from '@/models/source';
import { callTool, createTestClient, getFirstText } from './mcpTestUtils';
jest.setTimeout(10 * 60 * 1000);
const mockFetch = jest.mocked(global.fetch);
const { realFetch } = globalThis as unknown as { realFetch: typeof fetch };
const TABLE = 'repro_promql_catalog';
const SERIES = Number(process.env.REPRO_SERIES ?? 1_000_000);
const METRICS = 200;
describe(`repro: describe_source on a PromQL source with ${SERIES.toLocaleString('en-US')} series`, () => {
const server = getServer();
let client: Client;
let team: any;
let connectionId: any;
beforeAll(async () => {
await server.start();
await dropTimeSeriesTable({ table: TABLE });
// Schema version 8 (the 26.10 default): series time ranges live in their own inner table.
await executeTimeSeriesSqlCommand(`CREATE TABLE ${DEFAULT_DATABASE}.${TABLE} ENGINE = TimeSeries SETTINGS version = 8`);
await executeTimeSeriesSqlCommand(
`INSERT INTO TABLE FUNCTION timeSeriesTags('${DEFAULT_DATABASE}', '${TABLE}') (metric_name, tags)
SELECT 'metric_' || toString(number % ${METRICS}), map('pod', 'pod-' || toString(number), 'job', 'job-' || toString(number % 10))
FROM numbers(${SERIES})`,
);
// Every series active in the last hour, so all of them are inside describe_source's 24 h window.
await executeTimeSeriesSqlCommand(
`INSERT INTO TABLE FUNCTION timeSeriesTimeRanges('${DEFAULT_DATABASE}', '${TABLE}')
SELECT id, now64(3) - 3600, now64(3) FROM timeSeriesTags('${DEFAULT_DATABASE}', '${TABLE}')`,
);
const agent = await getLoggedInAgent(server);
team = agent.team;
const user = agent.user;
const connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
connectionId = connection._id;
client = await createTestClient({ teamId: team._id.toString(), userId: user._id.toString(), promqlEnabled: true });
});
afterAll(async () => {
await client?.close();
await dropTimeSeriesTable({ table: TABLE });
await server.clearDBs();
await server.stop();
});
afterEach(() => {
clearBackendProbeCache();
mockFetch.mockReset();
});
const ch = () => getTestFixtureClickHouseClient();
// ClickHouse's clock, so query_log filtering doesn't depend on the test host's clock.
const sinceMs = async () =>
Number(
(
await (await ch())
.query({ query: 'SELECT toUnixTimestamp64Milli(now64(3)) AS t', format: 'JSONEachRow' })
.then(r => r.json<{ t: string }>())
)[0].t,
);
/** Memory and rows of the label/metric-name lookups ClickHouse ran since `since`. */
const lookups = async (since: number) => {
await (await ch()).command({ query: 'SYSTEM FLUSH LOGS' });
const uuid = await (await ch())
.query({ query: `SELECT toString(uuid) AS u FROM system.tables WHERE database = '${DEFAULT_DATABASE}' AND name = '${TABLE}'`, format: 'JSONEachRow' })
.then(r => r.json<{ u: string }>());
const rows = await (await ch())
.query({
query: `
SELECT if(query ILIKE '%timeSeriesStoreTags%', 'prometheus_api_v1 handler', 'SQL') AS path,
if(query ILIKE '%mapKeys%' OR query ILIKE '%tupleElement(timeSeriesIdToTags(series_id), 1)%', 'label names', 'metric names') AS lookup,
type, query_duration_ms AS ms, read_rows, memory_usage
FROM system.query_log
WHERE event_time_microseconds >= fromUnixTimestamp64Milli(${since}) AND type != 'QueryStart' AND query_kind = 'Select'
AND (query ILIKE '%timeSeriesTimeRanges(%GROUP BY id%' OR query ILIKE '%${uuid[0].u}%')
AND query NOT ILIKE '%system.query_log%'
ORDER BY event_time_microseconds`,
format: 'JSONEachRow',
})
.then(r => r.json<Record<string, string>>());
return rows;
};
const mib = (b: string | number) => `${(Number(b) / 2 ** 20).toFixed(1)} MiB`;
const results: string[] = [];
let plainMemory = 0;
it('a plain metric-name listing reads one column and stays small', async () => {
const since = await sinceMs();
await (await ch()).query({
query: `SELECT DISTINCT metric_name FROM timeSeriesTags(${DEFAULT_DATABASE}, ${TABLE}) ORDER BY metric_name LIMIT 201 SETTINGS log_comment = 'repro-plain'`,
clickhouse_settings: { allow_experimental_time_series_table: 1 },
}).then(r => r.json());
await (await ch()).command({ query: 'SYSTEM FLUSH LOGS' });
const [row] = await (await ch())
.query({ query: `SELECT query_duration_ms AS ms, read_rows, memory_usage FROM system.query_log WHERE log_comment = 'repro-plain' AND type = 'QueryFinish' AND event_time_microseconds >= fromUnixTimestamp64Milli(${since})`, format: 'JSONEachRow' })
.then(r => r.json<Record<string, string>>());
plainMemory = Number(row.memory_usage);
results.push(`plain SELECT DISTINCT metric_name (tags table only) ${row.ms} ms rows=${row.read_rows} memory=${mib(row.memory_usage)}`);
});
it.each([
['table-function', 'SQL path (source pinned to table-function; CH < 26.9 or no handler)'],
[undefined, 'auto: prometheus_api_v1 handler on CH 26.9+ (#3628/#3630)'],
])('describe_source with promqlBackend=%s', async (promqlBackend, label) => {
mockFetch.mockImplementation(realFetch);
const source = await Source.create({
kind: SourceKind.Promql,
team: team._id,
connection: connectionId,
name: `PromQL ${promqlBackend ?? 'auto'}`,
from: { databaseName: DEFAULT_DATABASE, tableName: TABLE },
timestampValueExpression: 'timestamp',
...(promqlBackend ? { promqlBackend } : {}),
});
const since = await sinceMs();
const t0 = Date.now();
const result = await callTool(client, 'clickstack_describe_source', { sourceId: source._id.toString() });
const wall = Date.now() - t0;
const out = JSON.parse(getFirstText(result));
const queries = await lookups(since);
results.push(
`describe_source, ${label}: ${wall} ms wall, metricNames=${out.metricNames?.length ?? 0}, partial=${out.partial ?? false}`,
...queries.map(q => ` ${q.path.padEnd(26)} ${q.lookup.padEnd(13)} ${q.type.padEnd(14)} ${String(q.ms).padStart(5)} ms rows=${q.read_rows} memory=${mib(q.memory_usage)} (${(Number(q.memory_usage) / Math.max(plainMemory, 1)).toFixed(0)}x plain)`),
);
expect(queries.length).toBeGreaterThan(0);
for (const q of queries) expect(Number(q.memory_usage)).toBeGreaterThan(20 * plainMemory);
});
afterAll(() => {
console.log(`REPRO ${SERIES.toLocaleString('en-US')} series, ${METRICS} metric names\n${results.join('\n')}`);
});
});
Activity