Skip to content

PromQL describe_source can't list metric or label names on a large TimeSeries table: both catalog paths read every series id in the table #3648

Open

Description

@bcanvural

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 with arrayJoin(arrayConcat(['__name__'], mapKeys(tags))) for label names. The inner GROUP BY id builds a hash table of every series id in the time ranges table, whatever the window.
  • ClickHouse Prometheus HTTP API path (new on main with 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 the prometheus_api_v1 handler is configured): /labels and /label/__name__/values with start/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) ... plus groupUniqArrayArray(timeSeriesIdToTags(...)), with no LIMIT. 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 main at 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

  1. Metric names without the time-ranges join. Read SELECT DISTINCT metric_name FROM timeSeriesTags(...), with an optional name prefix or pattern and paging (as list_metrics has 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.
  2. Label names per metric. Add an optional metricName (or match) argument to describe_source for PromQL sources, and list that metric's label names with metric_name = … on the tags table (the same idea as perf(api): read only the matched metrics' tags rows in PromQL label lookups #3603 for match[]). Without it, return metric names only, or label names from a bounded read (max_rows_to_read with read_overflow_mode = 'break'), marked as a sample.
  3. Fail cheaply. Run the catalog lookups with a low max_memory_usage and max_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

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')}`);
  });
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions